diff --git a/.gitattributes b/.gitattributes index 1ff0c42..f2f876c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,22 @@ ############################################################################### * text=auto +############################################################################### +# The protocol definition is hashed, not just read. +# +# protocol.lock.json records a SHA-256 of protocol.json's bytes and the build +# verifies it. Under `* text=auto` a Windows checkout would rewrite the line +# endings and every fresh clone would fail that check, so these two are kept +# byte for byte as committed. +############################################################################### +protocol.json -text +protocol.lock.json -text + +############################################################################### +# Media the validation suite plays. Binary; normalising it would corrupt it. +############################################################################### +*.mp4 binary + ############################################################################### # Set default behavior for command prompt diff. # diff --git a/.github/fixtures/obsws-ci-scenes.json b/.github/fixtures/obsws-ci-scenes.json new file mode 100644 index 0000000..886721a --- /dev/null +++ b/.github/fixtures/obsws-ci-scenes.json @@ -0,0 +1,409 @@ +{ + "name": "obsws-ci", + "sources": [ + { + "prev_ver": 537001986, + "name": "CI Grouped Colour", + "uuid": "11111111-1111-4111-8111-111111111111", + "id": "color_source_v3", + "versioned_id": "color_source_v3", + "settings": { + "color": 4278190335, + "width": 400, + "height": 400 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e", + "private_settings": {} + }, + { + "prev_ver": 537001986, + "name": "CI Backdrop", + "uuid": "22222222-2222-4222-8222-222222222222", + "id": "color_source_v3", + "versioned_id": "color_source_v3", + "settings": { + "color": 4278190335, + "width": 400, + "height": 400 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e", + "private_settings": {} + }, + { + "prev_ver": 537001986, + "name": "CI Group", + "uuid": "33333333-3333-4333-8333-333333333333", + "id": "group", + "versioned_id": "group", + "settings": { + "id_counter": 1, + "custom_size": true, + "cx": 400, + "cy": 400, + "items": [ + { + "name": "CI Grouped Colour", + "source_uuid": "11111111-1111-4111-8111-111111111111", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 1920.0, + "y": 1080.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 1, + "group_item_backup": false, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "pos_rel": { + "x": -1.7777777910232544, + "y": -1.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e", + "private_settings": {} + }, + { + "prev_ver": 537001986, + "name": "Scene", + "uuid": "44444444-4444-4444-8444-444444444444", + "id": "scene", + "versioned_id": "scene", + "settings": { + "id_counter": 3, + "custom_size": false, + "items": [ + { + "name": "CI Group", + "source_uuid": "33333333-3333-4333-8333-333333333333", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 1920.0, + "y": 1080.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 2, + "group_item_backup": false, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "pos_rel": { + "x": -1.7777777910232544, + "y": -1.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "CI Backdrop", + "source_uuid": "22222222-2222-4222-8222-222222222222", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 1920.0, + "y": 1080.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 3, + "group_item_backup": false, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "pos_rel": { + "x": -1.7777777910232544, + "y": -1.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e", + "private_settings": {} + } + ], + "groups": [ + { + "prev_ver": 537001986, + "name": "CI Group", + "uuid": "33333333-3333-4333-8333-333333333333", + "id": "group", + "versioned_id": "group", + "settings": { + "id_counter": 1, + "custom_size": true, + "cx": 400, + "cy": 400, + "items": [ + { + "name": "CI Grouped Colour", + "source_uuid": "11111111-1111-4111-8111-111111111111", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 1920.0, + "y": 1080.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 1, + "group_item_backup": false, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "pos_rel": { + "x": -1.7777777910232544, + "y": -1.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e", + "private_settings": {} + } + ], + "scene_order": [ + { + "name": "Scene" + } + ], + "current_scene": "Scene", + "current_program_scene": "Scene", + "canvases": [], + "current_transition": "CI Swipe", + "transition_duration": 300, + "transitions": [ + { + "name": "CI Swipe", + "id": "swipe_transition", + "settings": { + "direction": "left", + "swipe_in": false + } + } + ], + "preview_locked": false, + "scaling_enabled": false, + "scaling_level": 0, + "scaling_off_x": 0.0, + "scaling_off_y": 0.0, + "resolution": { + "x": 1920, + "y": 1080 + }, + "version": 2 +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b17001..721de47 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,16 +38,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Required for MinVer to determine the version from Git history - - name: Setup .NET 10 SDK - uses: actions/setup-dotnet@v6 + - name: Setup .NET SDKs + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: | + 9.x 10.x - 11.0.100-preview.7.26381.103 + 11.0.100-rc.1.26425.128 - name: Restore dependencies run: dotnet restore ObsWebSocket.sln @@ -60,36 +61,16 @@ jobs: - name: Build Solution run: dotnet build ObsWebSocket.sln --configuration Release --no-restore - - name: Restore Example for AOT RID graph - run: > - dotnet restore ObsWebSocket.Example/ObsWebSocket.Example.csproj - -p:TargetFramework=net10.0 - --runtime linux-x64 - -p:SelfContained=true - -p:PublishAot=true - - - name: Native AOT Smoke Publish (ObsWebSocket.Example) - run: > - dotnet publish ObsWebSocket.Example/ObsWebSocket.Example.csproj - --configuration Release - --framework net10.0 - --runtime linux-x64 - --self-contained true - /p:PublishAot=true - /p:ILLinkTreatWarningsAsErrors=true - /p:IlcTreatWarningsAsErrors=true - /p:WarningsNotAsErrors=IL2104%3BIL3053 - - name: Run Unit Tests - # Run tests specifically for the ObsWebSocket.Tests project - # Exclude integration tests which require a live OBS instance + # Integration tests need a live OBS; the live-obs workflow runs those. run: > - dotnet test ObsWebSocket.Tests/ObsWebSocket.Tests.csproj + dotnet test --project ${{ github.workspace }}/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj --configuration Release --no-build --verbosity normal - --filter "TestCategory!=Integration" - -- --coverage --coverage-settings coverage.settings.xml --coverage-output-format cobertura --coverage-output coverage.cobertura.xml + --results-directory-layout per-module + -- --filter "TestCategory!=Integration" + --coverage --coverage-settings coverage.settings.xml --coverage-output-format cobertura --coverage-output coverage.cobertura.xml # --- Packing is now done on push to master OR on release event --- - name: Summarize coverage @@ -136,7 +117,7 @@ jobs: - name: Upload coverage report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: coverage if-no-files-found: warn @@ -157,22 +138,94 @@ jobs: # exact package files. Anyone who downloads the package can verify it came from this repository # rather than from someone who republished it under the same name. if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || github.event_name == 'release' - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: subject-path: ${{ env.NuGetDirectory }}/*.nupkg - name: Upload NuGet Package Artifact (on master push or release) # Upload if it's a push to master OR if the event is a release (so publish job can get it) if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || github.event_name == 'release' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: nuget-package if-no-files-found: error retention-days: 1 path: ${{ env.NuGetDirectory }}/*.nupkg + native_aot: + name: Native AOT (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + rid: linux-x64 + - os: windows-latest + rid: win-x64 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: | + 10.x + 11.0.100-rc.1.26425.128 + + - name: Restore for the AOT RID graph + run: > + dotnet restore ObsWebSocket.Example/ObsWebSocket.Example.csproj + -p:TargetFramework=net10.0 + --runtime ${{ matrix.rid }} + -p:SelfContained=true + -p:PublishAot=true + + - name: Publish Native AOT + # TrimmerSingleWarn=false itemises warnings instead of collapsing them into the IL2104 and + # IL3053 aggregates, so each can be attributed to the assembly that raised it. + run: > + dotnet publish ObsWebSocket.Example/ObsWebSocket.Example.csproj + --configuration Release + --framework net10.0 + --runtime ${{ matrix.rid }} + --self-contained true + -p:PublishAot=true + -p:TrimmerSingleWarn=false + 2>&1 | tee aot-${{ matrix.rid }}.log + shell: bash + + - name: Check that no AOT warning comes from this library + # MessagePack resolves formatters reflectively and accounts for every warning this publish + # raises. Failing on all of them would fail on the dependency; excusing all of them would + # hide ours. The gate is attribution: this library must contribute none. + shell: bash + run: | + set -euo pipefail + if grep -E 'IL[0-9]{4}:' "aot-${{ matrix.rid }}.log" \ + | grep -vE 'IL[0-9]{4}: MessagePack' > offending.log; then + echo "::error::Native AOT warnings were raised outside the MessagePack dependency." + cat offending.log + exit 1 + fi + echo "No AOT warning outside MessagePack." + grep -cE 'IL[0-9]{4}: MessagePack' "aot-${{ matrix.rid }}.log" \ + | xargs -I{} echo "MessagePack accounted for {} warnings." >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the AOT log + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: aot-${{ matrix.rid }} + if-no-files-found: warn + path: aot-${{ matrix.rid }}.log + publish_nuget: name: Publish NuGet Package - needs: [build_and_test] + needs: [build_and_test, native_aot] if: github.event_name == 'release' && github.event.action == 'published' runs-on: ubuntu-latest # Declaring an environment is what makes GitHub record a deployment, which is what @@ -186,16 +239,16 @@ jobs: steps: - name: Download NuGet Package Artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: nuget-package path: ${{ env.NuGetDirectory }} - name: Setup .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - name: NuGet login - uses: NuGet/login@v1 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 id: login with: user: Agash diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index befdcc1..12f1d71 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,21 +24,21 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive - name: Setup .NET SDK # Install both SDKs and let global.json pick: setup-dotnet resolving the pinned preview from # global.json alone left the runner on its preinstalled 10.0.x, which cannot target net11.0. - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: | 10.0.x 11.0.x - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4 with: languages: csharp build-mode: manual @@ -49,6 +49,6 @@ jobs: run: dotnet build ObsWebSocket.sln --configuration Release - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4 with: category: "/language:csharp" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index e7e424c..25d36b4 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -22,7 +22,7 @@ jobs: # the pull request's own code is checked out or executed here. - name: Fetch update metadata id: metadata - uses: dependabot/fetch-metadata@v3 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index aee257a..eba99aa 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Review dependency changes - uses: actions/dependency-review-action@v5 + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: moderate comment-summary-in-pr: on-failure diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 673673f..60fbdf4 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup .NET SDK - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: | 10.0.x @@ -37,6 +37,6 @@ jobs: run: dotnet restore - name: Detect and submit dependencies - uses: advanced-security/component-detection-dependency-submission-action@v0.1.6 + uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6 with: filePath: . diff --git a/.github/workflows/live-obs.yml b/.github/workflows/live-obs.yml new file mode 100644 index 0000000..c5a0ef1 --- /dev/null +++ b/.github/workflows/live-obs.yml @@ -0,0 +1,155 @@ +# The protocol contract is only testable against a real OBS: field order, numeric width, +# nullability and response shape are invisible to a suite built on synthetic payloads. +# +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: Live OBS validation + +on: + workflow_dispatch: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + # Weekly, to catch an OBS release that changes a payload. + - cron: "17 5 * * 2" + +permissions: + contents: read + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + OBS_WEBSOCKET_PORT: "4455" + OBS_WEBSOCKET_PASSWORD: "obsws-ci-password" + +jobs: + validate: + name: Validate JSON and MessagePack against OBS + runs-on: ubuntu-24.04 + timeout-minutes: 25 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: | + 10.x + 11.0.100-rc.1.26425.128 + + - name: Install OBS Studio + # The project PPA, not the distribution archive, which lags upstream. + run: | + set -euo pipefail + sudo add-apt-repository -y ppa:obsproject/obs-studio + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + obs-studio xvfb x11-utils pulseaudio netcat-openbsd + + - name: Report the versions under test + run: | + set -euo pipefail + obs --version | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Configure obs-websocket + # Before first launch: OBS otherwise generates a random password nothing can connect with. + run: | + set -euo pipefail + mkdir -p ~/.config/obs-studio/plugin_config/obs-websocket + cat > ~/.config/obs-studio/plugin_config/obs-websocket/config.json < ~/.config/obs-studio/user.ini <> "$GITHUB_ENV" + pulseaudio --start --exit-idle-time=-1 + pactl load-module module-null-sink sink_name=ci_sink + + - name: Start OBS + env: + DISPLAY: ":99" + run: | + set -euo pipefail + obs --minimize-to-tray --disable-updater --disable-shutdown-check \ + > obs-stdout.log 2>&1 & + echo $! > obs.pid + + # Waited for rather than slept: pipeline init time varies with the runner. + for _ in $(seq 1 90); do + if nc -z localhost "${OBS_WEBSOCKET_PORT}"; then + echo "obs-websocket is listening." + exit 0 + fi + sleep 2 + done + + echo "::error::obs-websocket never started listening on ${OBS_WEBSOCKET_PORT}." + cat obs-stdout.log + exit 1 + + - name: Validate both transports + # One process, both wire formats, one OBS. Exits non-zero on failure. + env: + DISPLAY: ":99" + Obs__ServerUri: ws://localhost:4455 + Obs__Password: ${{ env.OBS_WEBSOCKET_PASSWORD }} + run: > + dotnet run --project ObsWebSocket.Example --configuration Release + -- run-transport-tests + + - name: Collect OBS logs + if: always() + run: | + set -euo pipefail + mkdir -p obs-logs + cp obs-stdout.log obs-logs/ 2>/dev/null || true + cp -r ~/.config/obs-studio/logs obs-logs/obs-studio-logs 2>/dev/null || true + + - name: Upload OBS logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: obs-logs + if-no-files-found: warn + path: obs-logs + + - name: Stop OBS + if: always() + run: | + # Terminated, not killed, so OBS clears its unclean-shutdown marker. + if [ -f obs.pid ]; then + kill "$(cat obs.pid)" 2>/dev/null || true + sleep 5 + fi diff --git a/.gitignore b/.gitignore index 1d3bef5..86a48c6 100644 --- a/.gitignore +++ b/.gitignore @@ -364,7 +364,6 @@ FodyWeavers.xsd obswebsocket.xml obs-client-example-js.xml /ObsWebSocket.Tests/testsettings.local.json -protocol.json # The coverage scope config is source, not a coverage report. !coverage.settings.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e039b8d..d7c8cb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,18 +62,24 @@ To contribute code, you'll need to set up a local development environment: *(This will also run the source generators)* 4. **Run Unit Tests:** ```bash - dotnet test ObsWebSocket.Tests/ObsWebSocket.Tests.csproj + dotnet test --project ObsWebSocket.Tests -- --filter "TestCategory!=Integration" ``` 5. **(Optional) Run Integration Tests:** - * These require a running OBS instance configured with specific scenes/sources (details TBD). - * Use the Test Explorer in your IDE or `dotnet test --filter TestCategory=Integration`. + * These need a running OBS with obs-websocket enabled; point `Obs__ServerUri` and + `Obs__Password` at it. + * `dotnet test --project ObsWebSocket.Tests -- --filter "TestCategory=Integration"` +6. **(Optional) Validate both wire formats against that OBS:** + ```bash + dotnet run --project ObsWebSocket.Example -- run-transport-tests + ``` + Exits non-zero on the first failed check. ## Pull Request Process 🚀 -1. **Fork the repository** and create your branch from `main`. +1. **Fork the repository** and create your branch from `master`. 2. **Make your changes.** Ensure code follows the project's style guidelines. 3. **Add tests** for any new functionality or bug fixes. -4. **Ensure all tests pass** (`dotnet test`). +4. **Ensure all tests pass** (`dotnet test --project ObsWebSocket.Tests`). 5. **Update documentation** (README.md, XML comments) if you added or changed APIs. 6. **Commit your changes** using descriptive commit messages. 7. **Push your branch** to your fork. @@ -107,6 +113,39 @@ Thank you again for your interest in contributing! on every build. Serialization goes through a source-generated `JsonSerializerContext`, never the reflection-based `JsonSerializer` overloads. +## Connection state + +`ObsConnectionContext` (internal, `Core/Networking`) owns one connection: socket, serializer, +`ObsConnectionSettings`, cancellation and handshake waiters. The client holds one at a time and +replaces it whole, which keeps those parts from disagreeing with each other. + +When working there: + +- Do not add a mutable connection field to the client. It belongs on the context. +- Take the connection into a local via `RequireConnection()` so one operation cannot straddle two. +- Decode with the connection's serializer, threaded through the dispatch path, never a field. +- `Close()` is synchronous and callable from inside the receive loop; `DisposeAsync()` also awaits + that loop, so a server-initiated close must use `Close()`. +- Options a connection is built from go in `ObsConnectionSettings`; the rest stay on the monitor and + take effect without a reconnect. + +## The protocol definition + +The request and event types are generated from `protocol.json`, which is checked in and pinned by +upstream commit and SHA-256 in `protocol.lock.json`. The build verifies that hash and never fetches +anything, so the same revision of this repository always generates the same public API. + +Editing `protocol.json` by hand therefore fails the build. To move to a newer upstream revision: + +```bash +dotnet build ObsWebSocket.Core -t:RefreshObsProtocol -p:ObsProtocolCommit= +``` + +That fetches exactly that commit, re-pins the lock and regenerates. Commit the generated diff along +with `protocol.json` and `protocol.lock.json`, and run the live validation +(`ObsWebSocket.Example run-transport-tests`) before opening the pull request: a refresh is the change +most likely to alter a field's order, width or nullability, and that is not visible to the compiler. + ## Tests - Name tests `{Method}_{Scenario}_{ExpectedResult}`. diff --git a/ObsWebSocket.Codegen.Tasks/GenerateObsWebSocketSourcesTask.cs b/ObsWebSocket.Codegen.Tasks/GenerateObsWebSocketSourcesTask.cs index 54f15d0..81e04cb 100644 --- a/ObsWebSocket.Codegen.Tasks/GenerateObsWebSocketSourcesTask.cs +++ b/ObsWebSocket.Codegen.Tasks/GenerateObsWebSocketSourcesTask.cs @@ -10,7 +10,14 @@ public sealed class GenerateObsWebSocketSourcesTask : Microsoft.Build.Utilities. [Required] public string OutputDirectory { get; set; } = string.Empty; - public bool DownloadIfMissing { get; set; } + /// + /// An upstream commit to refresh the checked-in protocol definition to before generating. + /// + /// + /// Empty for every ordinary build, which then generates purely from repository content. Set + /// only by an explicit refresh, so the network is never a silent build input. + /// + public string RefreshCommit { get; set; } = string.Empty; public override bool Execute() { @@ -18,7 +25,7 @@ public override bool Execute() .GenerateAsync( protocolPath: ProtocolPath, outputDirectory: OutputDirectory, - downloadIfMissing: DownloadIfMissing, + refreshCommit: RefreshCommit, cancellationToken: CancellationToken.None, logInfo: message => Log.LogMessage(MessageImportance.High, message), logWarning: message => Log.LogWarning(message), diff --git a/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs b/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs index 9965c0d..a98673a 100644 --- a/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs +++ b/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs @@ -6,13 +6,10 @@ namespace ObsWebSocket.Codegen.Tasks; internal static class ProtocolCodegenRunner { - private const string ProtocolUrl = - "https://raw.githubusercontent.com/obsproject/obs-websocket/master/docs/generated/protocol.json"; - public static async Task GenerateAsync( string protocolPath, string outputDirectory, - bool downloadIfMissing, + string? refreshCommit, CancellationToken cancellationToken, Action? logInfo = null, Action? logWarning = null, @@ -26,22 +23,49 @@ public static async Task GenerateAsync( { string fullProtocolPath = Path.GetFullPath(protocolPath); string fullOutputDirectory = Path.GetFullPath(outputDirectory); + ProtocolLock pinned = ProtocolLock.Read(fullProtocolPath); - if (!File.Exists(fullProtocolPath)) + if (!string.IsNullOrEmpty(refreshCommit)) { - if (!downloadIfMissing) - { - logError?.Invoke($"Protocol file not found: {fullProtocolPath}"); - return 2; - } - - await DownloadProtocolAsync(fullProtocolPath, cancellationToken) + pinned = await RefreshProtocolAsync( + fullProtocolPath, + pinned, + refreshCommit, + cancellationToken, + logInfo + ) .ConfigureAwait(false); - logInfo?.Invoke($"Downloaded protocol.json to '{fullProtocolPath}'."); } - string protocolJson = await File.ReadAllTextAsync(fullProtocolPath, cancellationToken) + if (!File.Exists(fullProtocolPath)) + { + // Not downloaded: the generated types are this library's public API, so a build + // must derive them from repository content. + logError?.Invoke( + $"Protocol definition not found: {fullProtocolPath}. It is checked in, so " + + "restore it from git rather than regenerating it, or run the target " + + "'RefreshObsProtocol' to fetch the pinned revision explicitly." + ); + return 2; + } + + byte[] protocolBytes = await ReadAllBytesAsync(fullProtocolPath, cancellationToken) .ConfigureAwait(false); + string actualHash = ProtocolLock.HashOf(protocolBytes); + if (!string.Equals(actualHash, pinned.Sha256, StringComparison.Ordinal)) + { + logError?.Invoke( + $"'{fullProtocolPath}' does not match the revision pinned in " + + $"{ProtocolLock.FileName}.{Environment.NewLine}" + + $" expected {pinned.Sha256} (upstream {pinned.Commit}){Environment.NewLine}" + + $" actual {actualHash}{Environment.NewLine}" + + "Edit the definition by refreshing it to a new upstream commit, so the " + + "lock records where the generated API came from." + ); + return 2; + } + + string protocolJson = Encoding.UTF8.GetString(protocolBytes); (IReadOnlyDictionary sources, IReadOnlyList diagnostics) = ProtocolCodeGenerator.Generate(protocolJson); @@ -89,26 +113,65 @@ .. diagnostics.Where(d => d.Severity == DiagnosticSeverity.Warning), } } - private static async Task DownloadProtocolAsync( + /// + /// Fetches an upstream revision and re-pins the lock to it. The only path here that touches + /// the network, addressed by commit so the lock records exactly what was fetched. + /// + private static async Task RefreshProtocolAsync( string protocolPath, - CancellationToken cancellationToken + ProtocolLock pinned, + string commit, + CancellationToken cancellationToken, + Action? logInfo ) { + ProtocolLock target = pinned with { Commit = commit }; _ = Directory.CreateDirectory(Path.GetDirectoryName(protocolPath)!); + using HttpClient http = new(); - using HttpResponseMessage response = await http.GetAsync(ProtocolUrl, cancellationToken) + using HttpResponseMessage response = await http.GetAsync(target.RawUrl, cancellationToken) .ConfigureAwait(false); _ = response.EnsureSuccessStatusCode(); - string protocolJson = await response - .Content.ReadAsStringAsync(cancellationToken) + byte[] protocolBytes = await response + .Content.ReadAsByteArrayAsync(cancellationToken) .ConfigureAwait(false); - await File.WriteAllTextAsync( - protocolPath, - protocolJson, - new UTF8Encoding(false), - cancellationToken - ) + + string hash = ProtocolLock.HashOf(protocolBytes); + await WriteAllBytesAsync(protocolPath, protocolBytes, cancellationToken) .ConfigureAwait(false); + pinned.Write(protocolPath, commit, hash); + + logInfo?.Invoke( + hash == pinned.Sha256 + ? $"Protocol definition at {commit} is identical to the pinned revision." + : $"Refreshed the protocol definition to {commit} ({hash})." + ); + + return target with + { + Sha256 = hash, + }; + } + + private static async Task ReadAllBytesAsync( + string path, + CancellationToken cancellationToken + ) + { + using FileStream stream = File.OpenRead(path); + using MemoryStream buffer = new(); + await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + return buffer.ToArray(); + } + + private static async Task WriteAllBytesAsync( + string path, + byte[] bytes, + CancellationToken cancellationToken + ) + { + using FileStream stream = File.Create(path); + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); } private static void WriteSources( diff --git a/ObsWebSocket.Codegen.Tasks/ProtocolLock.cs b/ObsWebSocket.Codegen.Tasks/ProtocolLock.cs new file mode 100644 index 0000000..8d87743 --- /dev/null +++ b/ObsWebSocket.Codegen.Tasks/ProtocolLock.cs @@ -0,0 +1,116 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace ObsWebSocket.Codegen.Tasks; + +/// +/// The pinned upstream revision that protocol.json was taken from. +/// +/// The upstream repository the definition comes from. +/// The path to the definition within that repository. +/// The upstream commit the definition was read at. +/// Lowercase hex SHA-256 of the definition's bytes. +internal sealed record ProtocolLock(string Repository, string Path, string Commit, string Sha256) +{ + /// The lock file's name, alongside protocol.json. + public const string FileName = "protocol.lock.json"; + + /// + /// Builds the immutable raw URL for the pinned revision. + /// + /// + /// Addressed by commit rather than by branch, so a refresh fetches a revision that cannot + /// change afterwards. + /// + public string RawUrl => + $"https://raw.githubusercontent.com/{RepositoryPath}/{Commit}/{Path.TrimStart('/')}"; + + private string RepositoryPath => new Uri(Repository).AbsolutePath.Trim('/'); + + /// Reads the lock sitting next to the given protocol file. + /// Full path to protocol.json. + /// The parsed lock. + /// Thrown if the lock is missing or incomplete. + public static ProtocolLock Read(string protocolPath) + { + string lockPath = LockPathFor(protocolPath); + if (!File.Exists(lockPath)) + { + throw new InvalidOperationException( + $"The protocol lock '{lockPath}' is missing. It records which upstream revision " + + "protocol.json was taken from, and generation will not run without it." + ); + } + + using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(lockPath)); + JsonElement root = document.RootElement; + + return new ProtocolLock( + Required(root, "repository", lockPath), + Required(root, "path", lockPath), + Required(root, "commit", lockPath), + Required(root, "sha256", lockPath).ToLowerInvariant() + ); + } + + /// Rewrites the lock for a newly fetched revision, preserving the comment block. + /// Full path to protocol.json. + /// The upstream commit that was fetched. + /// The fetched definition's hash. + public void Write(string protocolPath, string commit, string sha256) + { + string lockPath = LockPathFor(protocolPath); + using JsonDocument existing = JsonDocument.Parse(File.ReadAllBytes(lockPath)); + + using MemoryStream buffer = new(); + using (Utf8JsonWriter writer = new(buffer, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + foreach (JsonProperty property in existing.RootElement.EnumerateObject()) + { + switch (property.Name) + { + case "commit": + writer.WriteString("commit", commit); + break; + case "sha256": + writer.WriteString("sha256", sha256); + break; + default: + property.WriteTo(writer); + break; + } + } + + writer.WriteEndObject(); + } + + File.WriteAllText( + lockPath, + Encoding.UTF8.GetString(buffer.ToArray()) + Environment.NewLine, + new UTF8Encoding(false) + ); + } + + /// Hashes a protocol definition the same way the lock records it. + /// The definition's raw bytes. + /// Lowercase hex SHA-256. + public static string HashOf(byte[] bytes) => + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private static string LockPathFor(string protocolPath) => + System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(protocolPath))!, + FileName + ); + + private static string Required(JsonElement root, string name, string lockPath) => + root.TryGetProperty(name, out JsonElement value) + && value.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(value.GetString()) + ? value.GetString()! + : throw new InvalidOperationException( + $"The protocol lock '{lockPath}' has no '{name}'." + ); +} diff --git a/ObsWebSocket.Core/Events/EventStream.cs b/ObsWebSocket.Core/Events/EventStream.cs index 00efc2e..4896122 100644 --- a/ObsWebSocket.Core/Events/EventStream.cs +++ b/ObsWebSocket.Core/Events/EventStream.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading.Channels; @@ -40,6 +41,28 @@ public static IAsyncEnumerable Create( Action> unsubscribe, int capacity = DefaultCapacity, CancellationToken cancellationToken = default + ) + where TEventArgs : ObsEventArgs => + Create(subscribe, unsubscribe, capacity, metrics: null, cancellationToken); + + /// + /// Subscribes to an event, recording drops to the supplied instruments. + /// + /// The event args type carried by the event. + /// Attaches the supplied handler to the event. + /// Detaches the supplied handler from the event. + /// How many events to buffer when the consumer falls behind. + /// + /// Instruments to count drops on, or to use the shared ones. + /// + /// Ends the enumeration and unsubscribes. + /// An async sequence of events, running until canceled. + public static IAsyncEnumerable Create( + Action> subscribe, + Action> unsubscribe, + int capacity, + ObsWebSocketMetrics? metrics, + CancellationToken cancellationToken = default ) where TEventArgs : ObsEventArgs { @@ -49,13 +72,14 @@ public static IAsyncEnumerable Create( ArgumentNullException.ThrowIfNull(unsubscribe); ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); - return Iterate(subscribe, unsubscribe, capacity, cancellationToken); + return Iterate(subscribe, unsubscribe, capacity, metrics, cancellationToken); } private static async IAsyncEnumerable Iterate( Action> subscribe, Action> unsubscribe, int capacity, + ObsWebSocketMetrics? metrics, [EnumeratorCancellation] CancellationToken cancellationToken ) where TEventArgs : ObsEventArgs @@ -69,7 +93,22 @@ [EnumeratorCancellation] CancellationToken cancellationToken } ); - void Handler(object? sender, TEventArgs e) => channel.Writer.TryWrite(e); + ObsWebSocketMetrics instruments = metrics ?? ObsWebSocketMetrics.Shared; + TagList dropTags = new() { { "obsws.event_type", typeof(TEventArgs).Name } }; + + // Counted here rather than inferred from TryWrite, which reports success even when it + // evicted an older event to make room. Tracking the depth is the only way to tell the + // two apart, and an eviction is exactly what a consumer needs to be told about. + int buffered = 0; + + void Handler(object? sender, TEventArgs e) + { + if (channel.Writer.TryWrite(e) && Interlocked.Increment(ref buffered) > capacity) + { + _ = Interlocked.Decrement(ref buffered); + instruments.EventsDropped.Add(1, dropTags); + } + } subscribe(Handler); try @@ -80,6 +119,7 @@ TEventArgs item in channel .ConfigureAwait(false) ) { + _ = Interlocked.Decrement(ref buffered); yield return item; } } diff --git a/ObsWebSocket.Core/Networking/ObsConnectionContext.cs b/ObsWebSocket.Core/Networking/ObsConnectionContext.cs new file mode 100644 index 0000000..af37cae --- /dev/null +++ b/ObsWebSocket.Core/Networking/ObsConnectionContext.cs @@ -0,0 +1,120 @@ +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Core.Networking; + +/// +/// One connection to OBS: its socket, the serializer for the sub-protocol that socket negotiated, +/// the settings it was built from, its cancellation, and the handshake it is waiting on. +/// +/// +/// Replaced wholesale rather than mutated, so a connection cannot hold a serializer that disagrees +/// with its own settings. +/// +internal sealed class ObsConnectionContext : IAsyncDisposable +{ + private readonly CancellationTokenSource _closed; + private int _disposed; + + /// Builds a context for one connection attempt. + /// The socket this connection runs on. + /// The serializer matching ' format. + /// The settings this connection was built from. + /// The client-wide token, so disposing the client ends this too. + public ObsConnectionContext( + IWebSocketConnection transport, + IWebSocketMessageSerializer serializer, + ObsConnectionSettings settings, + CancellationToken clientLifetime + ) + { + Transport = transport; + Serializer = serializer; + Settings = settings; + _closed = CancellationTokenSource.CreateLinkedTokenSource(clientLifetime); + Hello = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + Identified = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + } + + /// The socket this connection runs on. + public IWebSocketConnection Transport { get; } + + /// The serializer for the sub-protocol this connection negotiated. + public IWebSocketMessageSerializer Serializer { get; } + + /// The settings this connection was established with. + public ObsConnectionSettings Settings { get; } + + /// Signalled when this connection ends, for any reason. + public CancellationToken ConnectionClosed => _closed.Token; + + /// Completed by the receive loop when OBS sends its Hello. + public TaskCompletionSource Hello { get; } + + /// Completed by the receive loop on Identified. + /// + /// Replaced per re-identification, which is sound only because re-identification is single + /// flight: Identified carries no request id, so concurrent waiters are indistinguishable. + /// + public TaskCompletionSource Identified { get; set; } + + /// The loop draining , once started. + public Task? ReceiveTask { get; set; } + + /// Ends the connection and tears the socket down without awaiting the receive loop. + /// + /// Callable from inside the receive loop, where a server-initiated close lands. + /// + public void Close() + { + try + { + _closed.Cancel(); + } + catch (ObjectDisposedException) + { + // Already torn down concurrently; the token is cancelled either way. + } + + try + { + Transport.Abort(); + } + catch + { + // A socket that already faulted is the normal path here. + } + + try + { + Transport.Dispose(); + } + catch (ObjectDisposedException) + { + // Disposed by a concurrent teardown. + } + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + Close(); + + if (ReceiveTask is { } receiveTask) + { + // Guarantees no receive loop outlives the connection it reads. Faults belong to the + // attempt's owner. + await receiveTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + _closed.Dispose(); + } +} diff --git a/ObsWebSocket.Core/Networking/ObsConnectionSettings.cs b/ObsWebSocket.Core/Networking/ObsConnectionSettings.cs new file mode 100644 index 0000000..f1f3376 --- /dev/null +++ b/ObsWebSocket.Core/Networking/ObsConnectionSettings.cs @@ -0,0 +1,89 @@ +using ObsWebSocket.Core.Protocol.Generated; + +namespace ObsWebSocket.Core.Networking; + +/// +/// The options that are fixed for the life of one connection, captured when it is established. +/// +/// +/// The sub-protocol is agreed during the handshake, so endpoint, credentials, format and +/// subscriptions cannot change underneath a live connection. Everything else stays on the options +/// monitor and is read per call. +/// +internal sealed record ObsConnectionSettings +{ + private ObsConnectionSettings(ObsWebSocketClientOptions options) + { + ServerUri = options.ServerUri!; + Password = options.Password; + Format = options.Format; + EventSubscriptions = options.EventSubscriptions ?? EventSubscription.All; + HandshakeTimeoutMs = options.HandshakeTimeoutMs; + AutoReconnectEnabled = options.AutoReconnectEnabled; + MaxReconnectAttempts = options.MaxReconnectAttempts; + InitialReconnectDelayMs = options.InitialReconnectDelayMs; + MaxReconnectDelayMs = options.MaxReconnectDelayMs; + + // Clamped on the copy: a directly constructed client has no validator, and writing back + // would change what every other reader of the monitored instance sees. + ReconnectBackoffMultiplier = Math.Max(1.0, options.ReconnectBackoffMultiplier); + } + + /// Where this connection was established to. + public Uri ServerUri { get; } + + /// The password presented during this connection's handshake. + public string? Password { get; } + + /// The wire format this connection negotiated. + public SerializationFormat Format { get; } + + /// The subscriptions requested when identifying. + public EventSubscription EventSubscriptions { get; } + + /// Timeout for the Hello/Identified exchange. + public int HandshakeTimeoutMs { get; } + + /// Whether a lost connection is retried. + public bool AutoReconnectEnabled { get; } + + /// Retry ceiling; negative means unlimited. + public int MaxReconnectAttempts { get; } + + /// Delay before the first retry. + public int InitialReconnectDelayMs { get; } + + /// Ceiling on the backoff delay. + public int MaxReconnectDelayMs { get; } + + /// Backoff growth per attempt, never below 1.0. + public double ReconnectBackoffMultiplier { get; } + + /// + /// Captures the values a connection is built from. + /// + /// The options to read, normally the current monitored value. + /// An immutable snapshot. + /// Thrown if no server URI is configured. + public static ObsConnectionSettings Capture(ObsWebSocketClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options.ServerUri, nameof(options.ServerUri)); + return new ObsConnectionSettings(options); + } + + /// + /// Reports whether a configuration change requires a new connection. + /// + /// Only the values the socket is built from count. + /// The updated options. + /// when the live connection no longer matches. + public bool RequiresNewConnection(ObsWebSocketClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return ServerUri != options.ServerUri + || Password != options.Password + || Format != options.Format + || EventSubscriptions != (options.EventSubscriptions ?? EventSubscription.All); + } +} diff --git a/ObsWebSocket.Core/ObsWebSocket.Core.csproj b/ObsWebSocket.Core/ObsWebSocket.Core.csproj index 1a786a9..7f17cbd 100644 --- a/ObsWebSocket.Core/ObsWebSocket.Core.csproj +++ b/ObsWebSocket.Core/ObsWebSocket.Core.csproj @@ -1,4 +1,4 @@ - + + 15.0 ObsWebSocket.Core ObsWebSocket.Core ObsWebSocket.Core @@ -114,7 +116,6 @@ + + + + + diff --git a/ObsWebSocket.Core/ObsWebSocketClient.cs b/ObsWebSocket.Core/ObsWebSocketClient.cs index 9222d1c..8cfa6e6 100644 --- a/ObsWebSocket.Core/ObsWebSocketClient.cs +++ b/ObsWebSocket.Core/ObsWebSocketClient.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ObsWebSocket.Core.Events; @@ -47,30 +48,53 @@ internal enum ConnectionState /// /// Initializes a new instance of the class. /// -public sealed partial class ObsWebSocketClient( - ILogger logger, - IWebSocketMessageSerializer serializer, - IOptions options, - IWebSocketConnectionFactory? connectionFactory = null, - TimeProvider? timeProvider = null, - ObsWebSocketMetrics? metrics = null -) : IAsyncDisposable +public sealed partial class ObsWebSocketClient : IAsyncDisposable { + #region Construction + + /// + /// Initializes a client that selects its serializer per connection. + /// + /// + /// To fix a client to one serializer, pass a factory that ignores the format: + /// _ => serializer. + /// + /// Logger for connection and protocol activity. + /// Supplies the serializer for a format. + /// The client's options, read live. + /// Creates the underlying sockets. + /// Source of time for timeouts and backoff. + /// The instruments to record to. + public ObsWebSocketClient( + ILogger logger, + ObsSerializerFactory serializerFactory, + IOptions options, + IWebSocketConnectionFactory? connectionFactory = null, + TimeProvider? timeProvider = null, + ObsWebSocketMetrics? metrics = null + ) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _serializerFactory = + serializerFactory ?? throw new ArgumentNullException(nameof(serializerFactory)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _connectionFactory = connectionFactory ?? new WebSocketConnectionFactory(); + _timeProvider = timeProvider ?? TimeProvider.System; + _metrics = metrics ?? ObsWebSocketMetrics.Shared; + } + + #endregion + #region Fields - internal readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly IWebSocketMessageSerializer _serializer = - serializer ?? throw new ArgumentNullException(nameof(serializer)); - internal readonly IOptions _options = - options ?? throw new ArgumentNullException(nameof(options)); - private readonly IWebSocketConnectionFactory _connectionFactory = - connectionFactory ?? new WebSocketConnectionFactory(); + internal readonly ILogger _logger; + private readonly ObsSerializerFactory _serializerFactory; + internal readonly IOptions _options; + private readonly IWebSocketConnectionFactory _connectionFactory; /// Source of time for all timeouts and reconnect delays. - internal readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + internal readonly TimeProvider _timeProvider; - private readonly ObsWebSocketMetrics _metrics = metrics ?? ObsWebSocketMetrics.Shared; - - private ReconnectDelays _reconnectDelays = ReconnectDelays.Disabled; + private readonly ObsWebSocketMetrics _metrics; /// /// Default size of the receive buffer for WebSocket messages. @@ -92,17 +116,36 @@ public sealed partial class ObsWebSocketClient( /// public const int DefaultBatchTimeoutMultiplier = 2; - private IWebSocketConnection? _webSocket; - private CancellationTokenSource? _receiveCts; - private Task? _receiveTask; + /// + /// Default ceiling on the size of a single inbound message, in bytes (64 MiB). + /// + /// + /// Chosen to sit well above the largest response OBS realistically sends - a 4K + /// GetSourceScreenshot data URI runs to single-digit megabytes - while still bounding + /// what a peer can make this process allocate. + /// + public const int DefaultMaxIncomingMessageBytes = 64 * 1024 * 1024; + + /// + /// The live connection, or when there is none. + /// + private volatile ObsConnectionContext? _connection; + private volatile ConnectionState _connectionState = ConnectionState.Disconnected; private Task? _connectionLoopTask; private CancellationTokenSource? _clientLifetimeCts; private readonly Lock _connectionLock = new(); private Exception? _completionException; - private TaskCompletionSource? _helloTcs; - private TaskCompletionSource? _identifiedTcs; + /// + /// Serializes re-identification, which the protocol cannot correlate on its own. + /// + /// + /// Identified carries no request id, so concurrent operations cannot be correlated to + /// their replies. + /// + private readonly SemaphoreSlim _reidentifyGate = new(1, 1); + private readonly ConcurrentDictionary> _pendingRequests = new(); private readonly ConcurrentDictionary< @@ -167,15 +210,8 @@ private readonly ConcurrentDictionary< /// public async Task ConnectAsync(CancellationToken cancellationToken = default) { - ObsWebSocketClientOptions currentOptions = _options.Value; - ArgumentNullException.ThrowIfNull( - currentOptions.ServerUri, - nameof(currentOptions.ServerUri) - ); - if (currentOptions.ReconnectBackoffMultiplier < 1.0) - { - currentOptions.ReconnectBackoffMultiplier = 1.0; - } + // Captured once so a reload mid-sequence cannot change what is being connected to. + ObsConnectionSettings settings = ObsConnectionSettings.Capture(_options.Value); Task? loopTask; TaskCompletionSource currentInitialConnectionTcs; // Capture the TCS for this specific call @@ -197,22 +233,19 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) currentInitialConnectionTcs = _initialConnectionTcs; // Capture it loopTask = _connectionLoopTask = Task.Run( - () => ConnectionLoopAsync(currentOptions, cancellationToken), + () => ConnectionLoopAsync(settings, cancellationToken), CancellationToken.None ); } - _logger.LogStartingConnectionSequenceFor(currentOptions.ServerUri); + _logger.LogStartingConnectionSequenceFor(settings.ServerUri); try { // Await the initial connection TCS, not the whole loop task using CancellationTokenSource linkedTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - int overallTimeout = Math.Max( - options.Value.HandshakeTimeoutMs * 2, - DefaultRequestTimeoutMs - ); // Be generous + int overallTimeout = Math.Max(settings.HandshakeTimeoutMs * 2, DefaultRequestTimeoutMs); // Be generous linkedTimeoutCts.CancelAfterUsing( _timeProvider, TimeSpan.FromMilliseconds(overallTimeout) @@ -262,57 +295,74 @@ public async Task ReidentifyAsync( _clientLifetimeCts.Token ); - _identifiedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); // Reset for re-identify - int effectiveTimeout = timeoutMs ?? _options.Value.RequestTimeoutMs; - + // Held across send/wait/publish: holding it only for the send would leave two callers + // waiting on the same uncorrelated reply. + await _reidentifyGate.WaitAsync(linkedCts.Token).ConfigureAwait(false); try { - await SendMessageAsync( - WebSocketOpCode.Reidentify, - new ReidentifyPayload(eventSubscriptions), - linkedCts.Token - ) - .ConfigureAwait(false); - - _logger.LogWaitingForIdentifiedAfterReidentifyTimeoutMs(effectiveTimeout); - object identifiedMessageObj = await WaitForHandshakeMessageAsync( - _identifiedTcs, - effectiveTimeout, - "Identified (after Reidentify)", - _timeProvider, - linkedCts.Token - ) - .ConfigureAwait(false); - IdentifiedPayload identifiedPayload = ExtractPayloadFromHandshake( - identifiedMessageObj, - "Identified (after Reidentify)" + // Re-read after the gate: waiting for it may have outlasted the connection. + ObsConnectionContext connection = RequireConnection(); + TaskCompletionSource identified = new( + TaskCreationOptions.RunContinuationsAsynchronously ); + connection.Identified = identified; + int effectiveTimeout = timeoutMs ?? _options.Value.RequestTimeoutMs; + + try + { + await SendMessageAsync( + WebSocketOpCode.Reidentify, + new ReidentifyPayload(eventSubscriptions), + linkedCts.Token + ) + .ConfigureAwait(false); - _logger.LogReIdentificationSuccessfulRpcVersion(identifiedPayload.NegotiatedRpcVersion); + _logger.LogWaitingForIdentifiedAfterReidentifyTimeoutMs(effectiveTimeout); + object identifiedMessageObj = await WaitForHandshakeMessageAsync( + identified, + effectiveTimeout, + "Identified (after Reidentify)", + _timeProvider, + linkedCts.Token + ) + .ConfigureAwait(false); + IdentifiedPayload identifiedPayload = + ExtractPayloadFromHandshake( + connection, + identifiedMessageObj, + "Identified (after Reidentify)" + ); - NegotiatedRpcVersion = identifiedPayload.NegotiatedRpcVersion; - CurrentEventSubscriptions = eventSubscriptions is null - ? null - : (EventSubscription)eventSubscriptions.Value; - } - catch (Exception ex) - when (ex is not OperationCanceledException || cancellationToken.IsCancellationRequested) - { - _logger.LogReidentifyasyncFailed(ex); - _ = (_identifiedTcs?.TrySetException(ex)); - throw ex is ObsWebSocketException or OperationCanceledException - ? ex - : new ObsWebSocketException($"Reidentify failed: {ex.Message}", ex); - } - catch (OperationCanceledException) when (_clientLifetimeCts.IsCancellationRequested) - { - _logger.LogReidentifyasyncCanceledDueToClientShutdown(); - _ = (_identifiedTcs?.TrySetCanceled(_clientLifetimeCts.Token)); - throw; + _logger.LogReIdentificationSuccessfulRpcVersion( + identifiedPayload.NegotiatedRpcVersion + ); + + NegotiatedRpcVersion = identifiedPayload.NegotiatedRpcVersion; + CurrentEventSubscriptions = eventSubscriptions is null + ? null + : (EventSubscription)eventSubscriptions.Value; + } + catch (Exception ex) + when (ex is not OperationCanceledException + || cancellationToken.IsCancellationRequested + ) + { + _logger.LogReidentifyasyncFailed(ex); + _ = identified.TrySetException(ex); + throw ex is ObsWebSocketException or OperationCanceledException + ? ex + : new ObsWebSocketException($"Reidentify failed: {ex.Message}", ex); + } + catch (OperationCanceledException) when (_clientLifetimeCts.IsCancellationRequested) + { + _logger.LogReidentifyasyncCanceledDueToClientShutdown(); + _ = identified.TrySetCanceled(_clientLifetimeCts.Token); + throw; + } } finally { - _identifiedTcs = null; + _ = _reidentifyGate.Release(); } } @@ -458,7 +508,8 @@ await SendMessageAsync( // them, so attempting it fails on a request that actually succeeded. return typeof(TResponse) == typeof(object) ? null - : _serializer.DeserializePayload(response.ResponseData); + : RequireConnection() + .Serializer.DeserializePayload(response.ResponseData); } catch (Exception ex) when (ex is not OperationCanceledException || cancellationToken.IsCancellationRequested) @@ -562,9 +613,8 @@ await SendMessageAsync( ProcessResponseStatus(response.RequestStatus, requestType, requestId); - TResponse? result = _serializer.DeserializeValuePayload( - response.ResponseData - ); + TResponse? result = RequireConnection() + .Serializer.DeserializeValuePayload(response.ResponseData); return (!result.HasValue && Nullable.GetUnderlyingType(typeof(TResponse)) == null) ? throw new ObsWebSocketException( $"Null deserialization for non-nullable value type '{typeof(TResponse).Name}'." @@ -802,13 +852,12 @@ await DisconnectAsync( #region Connection Loop and Internal Logic private async Task ConnectionLoopAsync( - ObsWebSocketClientOptions options, + ObsConnectionSettings settings, CancellationToken externalCancellationToken ) { int attempt = 0; - _reconnectDelays = new ReconnectDelays(options); - IWebSocketConnection? previousWebSocket = null; + ReconnectDelays reconnectDelays = new(settings); Debug.Assert(_clientLifetimeCts != null); CancellationToken clientLifetimeToken = _clientLifetimeCts.Token; @@ -829,7 +878,7 @@ CancellationToken externalCancellationToken attempt++; bool isConnectedThisAttempt = false; Exception? attemptException = null; - IWebSocketConnection? currentWebSocket = null; + ObsConnectionContext? connection = null; try { @@ -848,9 +897,9 @@ CancellationToken externalCancellationToken if (attempt > 1) // Delay before Retry { - int maxAttempts = options.MaxReconnectAttempts; + int maxAttempts = settings.MaxReconnectAttempts; if ( - !options.AutoReconnectEnabled + !settings.AutoReconnectEnabled || (maxAttempts >= 0 && (attempt - 1) >= maxAttempts) ) { @@ -863,7 +912,7 @@ CancellationToken externalCancellationToken break; } - TimeSpan backoff = await _reconnectDelays + TimeSpan backoff = await reconnectDelays .GetDelayAsync(attempt - 2, loopToken) .ConfigureAwait(false); _logger.LogReconnectingAttemptAfterMs( @@ -875,12 +924,9 @@ CancellationToken externalCancellationToken await Task.Delay(backoff, _timeProvider, loopToken).ConfigureAwait(false); } - previousWebSocket?.Dispose(); - previousWebSocket = null; - currentWebSocket = _connectionFactory.CreateConnection(); - RaiseConnectingEvent(options.ServerUri!, attempt); + RaiseConnectingEvent(settings.ServerUri, attempt); - await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) + connection = await TryConnectAndIdentifyAsync(settings, attempt, loopToken) .ConfigureAwait(false); isConnectedThisAttempt = true; @@ -889,8 +935,6 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) if (_connectionState == ConnectionState.Disconnecting) { _logger.LogConnectedAttemptButDisconnectRequestedAborting(attempt); - previousWebSocket = currentWebSocket; - _webSocket = null; _completionException = new TaskCanceledException( "Disconnect requested during connect." ); @@ -900,14 +944,6 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) _connectionState = ConnectionState.Connected; IsConnected = true; - _webSocket = currentWebSocket; - previousWebSocket = null; // Prevent disposal - - // Receive loop is now started within TryConnectAndIdentifyAsync *before* handshake - Debug.Assert( - _receiveTask != null, - "Receive task should have been started by successful TryConnectAndIdentifyAsync." - ); _logger.LogAttemptHandshakeCompleteReceiveLoopIsRunning(attempt); } @@ -917,9 +953,9 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) _ = initialTcs.TrySetResult(); // Signal successful initial connection attempt = 0; // Reset attempt count only on full success - Debug.Assert(_receiveTask != null); + Debug.Assert(connection.ReceiveTask != null); _logger.LogConnectionEstablishedWaitingForReceiveLoopCompletion(); - await _receiveTask.WaitAsync(loopToken).ConfigureAwait(false); // Wait for disconnect/shutdown + await connection.ReceiveTask!.WaitAsync(loopToken).ConfigureAwait(false); // Wait for disconnect/shutdown _logger.LogReceiveLoopTaskCompletedWhileConnected(); } catch (OperationCanceledException ex) when (loopToken.IsCancellationRequested) @@ -933,7 +969,7 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) { _logger.LogAuthenticationFailedAttemptStopping(authEx, attempt); attemptException = authEx; - RaiseAuthenticationFailureEvent(options.ServerUri!, attempt, authEx); + RaiseAuthenticationFailureEvent(settings.ServerUri, attempt, authEx); _ = initialTcs.TrySetException(authEx); // Signal auth failure break; // Auth failure is fatal } @@ -944,11 +980,11 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) attempt ); attemptException = connEx; - RaiseConnectionFailedEvent(options.ServerUri!, attempt, connEx); + RaiseConnectionFailedEvent(settings.ServerUri, attempt, connEx); // Only fail initial TCS if retries are disabled or exhausted on the *first* attempt if ( attempt == 1 - && (!options.AutoReconnectEnabled || options.MaxReconnectAttempts == 0) + && (!settings.AutoReconnectEnabled || settings.MaxReconnectAttempts == 0) ) { _ = initialTcs.TrySetException(connEx); @@ -961,10 +997,10 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) attempt ); attemptException = wsEx; - RaiseConnectionFailedEvent(options.ServerUri!, attempt, wsEx); + RaiseConnectionFailedEvent(settings.ServerUri, attempt, wsEx); if ( attempt == 1 - && (!options.AutoReconnectEnabled || options.MaxReconnectAttempts == 0) + && (!settings.AutoReconnectEnabled || settings.MaxReconnectAttempts == 0) ) { _ = initialTcs.TrySetException( @@ -976,10 +1012,10 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) { _logger.LogUnexpectedErrorInConnectionLoopAttemptRetrying(ex, attempt); attemptException = ex; - RaiseConnectionFailedEvent(options.ServerUri!, attempt, ex); + RaiseConnectionFailedEvent(settings.ServerUri, attempt, ex); if ( attempt == 1 - && (!options.AutoReconnectEnabled || options.MaxReconnectAttempts == 0) + && (!settings.AutoReconnectEnabled || settings.MaxReconnectAttempts == 0) ) { _ = initialTcs.TrySetException( @@ -1004,19 +1040,21 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) if (_connectionState != ConnectionState.Disconnecting) { IsConnected = false; - _receiveCts?.Cancel(); - _receiveCts?.Dispose(); - _receiveCts = null; - _receiveTask = null; - if (ReferenceEquals(_webSocket, currentWebSocket)) - { - _webSocket = null; - } - - previousWebSocket = currentWebSocket; + } + + // A later attempt may already have published its own. + if (ReferenceEquals(_connection, connection)) + { + _connection = null; } } + if (connection is not null) + { + // Awaited so two receive loops never overlap across attempts. + await connection.DisposeAsync().ConfigureAwait(false); + } + if (isConnectedThisAttempt && attemptException != null) { _logger.LogConnectionLostDuringConnectedStateDueTo( @@ -1043,7 +1081,6 @@ await TryConnectAndIdentifyAsync(options, currentWebSocket, attempt, loopToken) "Connection loop exited unexpectedly before initial connection completed." ) ); - previousWebSocket?.Dispose(); await FinalizeDisconnectionAsync( WebSocketCloseStatus.NormalClosure, "Connection loop ended.", @@ -1053,49 +1090,55 @@ await FinalizeDisconnectionAsync( } } - /// Attempts a single connection and handshake sequence. Starts the receive loop upon successful connection. - private async Task TryConnectAndIdentifyAsync( - ObsWebSocketClientOptions options, - IWebSocketConnection ws, + /// + /// Makes one connection attempt and, if it succeeds, publishes it as the live connection. + /// + /// + /// The serializer comes from this attempt's settings, so the sub-protocol offered during + /// the handshake and the serializer used afterwards cannot drift apart. + /// + /// The settings this attempt connects with. + /// Attempt number, for logging. + /// Cancels the attempt. + /// The established connection. + private async Task TryConnectAndIdentifyAsync( + ObsConnectionSettings settings, int attempt, CancellationToken ct ) { - _helloTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - _identifiedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + Debug.Assert(_clientLifetimeCts != null); - CancellationTokenSource? localReceiveCts = null; - Task? localReceiveTask = null; + IWebSocketConnection ws = _connectionFactory.CreateConnection(); + IWebSocketMessageSerializer serializer = _serializerFactory(settings.Format); + ObsConnectionContext connection = new(ws, serializer, settings, _clientLifetimeCts.Token); try { // Add SubProtocol only if needed if ( string.IsNullOrEmpty(ws.SubProtocol) - || ws.SubProtocol != _serializer.ProtocolSubProtocol + || ws.SubProtocol != serializer.ProtocolSubProtocol ) { try { - ws.Options.AddSubProtocol(_serializer.ProtocolSubProtocol); + ws.Options.AddSubProtocol(serializer.ProtocolSubProtocol); } catch (ArgumentException ex) { - _logger.LogAttemptedDuplicateSubprotocolAdd( - ex, - _serializer.ProtocolSubProtocol - ); + _logger.LogAttemptedDuplicateSubprotocolAdd(ex, serializer.ProtocolSubProtocol); } } // Connect - using CancellationTokenSource connectTimeoutCts = new(options.HandshakeTimeoutMs); + using CancellationTokenSource connectTimeoutCts = new(settings.HandshakeTimeoutMs); using CancellationTokenSource linkedConnectCts = CancellationTokenSource.CreateLinkedTokenSource(ct, connectTimeoutCts.Token); _logger.LogAttemptConnecting(attempt); try { - await ws.ConnectAsync(options.ServerUri!, linkedConnectCts.Token) + await ws.ConnectAsync(settings.ServerUri, linkedConnectCts.Token) .ConfigureAwait(false); } catch (OperationCanceledException) when (connectTimeoutCts.IsCancellationRequested) @@ -1118,25 +1161,26 @@ await ws.ConnectAsync(options.ServerUri!, linkedConnectCts.Token) ); // --- Start Receive Loop *before* Handshake --- - localReceiveCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _webSocket = ws; // Assign the current socket temporarily for ReceiveLoopAsync - localReceiveTask = Task.Run( - () => ReceiveLoopAsync(localReceiveCts.Token), - localReceiveCts.Token - ); // TCS are now initialized before this runs + // Published first: the loop reads the connection, and Hello can arrive immediately. + _connection = connection; + connection.ReceiveTask = Task.Run( + () => ReceiveLoopAsync(connection), + connection.ConnectionClosed + ); _logger.LogAttemptReceiveLoopStartedForHandshake(attempt); // --- Handshake --- _logger.LogAttemptWaitingForHello(attempt); object helloMsgObj = await WaitForHandshakeMessageAsync( - _helloTcs, - options.HandshakeTimeoutMs, + connection.Hello, + settings.HandshakeTimeoutMs, "Hello", _timeProvider, ct ) .ConfigureAwait(false); HelloPayload helloPayload = ExtractPayloadFromHandshake( + connection, helloMsgObj, "Hello" ); @@ -1145,7 +1189,7 @@ await ws.ConnectAsync(options.ServerUri!, linkedConnectCts.Token) if (helloPayload.Authentication != null) { _logger.LogAttemptAuthenticationRequired(attempt); - if (string.IsNullOrEmpty(options.Password)) + if (string.IsNullOrEmpty(settings.Password)) { throw new AuthenticationFailureException( "Authentication required by server, but no password was provided." @@ -1155,12 +1199,11 @@ await ws.ConnectAsync(options.ServerUri!, linkedConnectCts.Token) authResponse = AuthenticationHelper.GenerateAuthenticationString( helloPayload.Authentication.Salt, helloPayload.Authentication.Challenge, - options.Password + settings.Password ); } - EventSubscription requestedEventSubs = - options.EventSubscriptions ?? EventSubscription.All; + EventSubscription requestedEventSubs = settings.EventSubscriptions; await SendMessageAsync( WebSocketOpCode.Identify, @@ -1174,8 +1217,8 @@ await SendMessageAsync( .ConfigureAwait(false); _logger.LogAttemptWaitingForIdentified(attempt); object identifiedMsgObj = await WaitForHandshakeMessageAsync( - _identifiedTcs, - options.HandshakeTimeoutMs, + connection.Identified, + settings.HandshakeTimeoutMs, "Identified", _timeProvider, ct @@ -1183,6 +1226,7 @@ await SendMessageAsync( .ConfigureAwait(false); IdentifiedPayload identifiedPayload = ExtractPayloadFromHandshake( + connection, identifiedMsgObj, "Identified" ); @@ -1193,43 +1237,27 @@ await SendMessageAsync( ); NegotiatedRpcVersion = identifiedPayload.NegotiatedRpcVersion; - CurrentEventSubscriptions = requestedEventSubs; // + CurrentEventSubscriptions = requestedEventSubs; - // Success! Transfer ownership of CTS/Task to class members. ConnectionLoopAsync sets final state. - _receiveCts = localReceiveCts; - _receiveTask = localReceiveTask; - localReceiveCts = null; // Prevent disposal in finally block - localReceiveTask = null; + return connection; } catch (Exception ex) { NegotiatedRpcVersion = null; CurrentEventSubscriptions = null; - _webSocket = null; // Detach socket on failure + _ = connection.Hello.TrySetException(ex); + _ = connection.Identified.TrySetException(ex); - // Ensure TCS are cleaned up on failure - _ = (_helloTcs?.TrySetException(ex)); - _ = (_identifiedTcs?.TrySetException(ex)); - _helloTcs = null; // Clear fields after attempting to set exception - _identifiedTcs = null; - - // Clean up this attempt's specific receive loop resources - try + using (_connectionLock.EnterScope()) { - localReceiveCts?.Cancel(); - } - catch - { /* Ignore */ + if (ReferenceEquals(_connection, connection)) + { + _connection = null; + } } - try - { - localReceiveCts?.Dispose(); - } - catch - { /* Ignore */ - } + await connection.DisposeAsync().ConfigureAwait(false); // Rethrow specific exceptions if ( @@ -1258,18 +1286,22 @@ ex is ObsWebSocketException obsEx } /// Receives messages from the WebSocket. - private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + private async Task ReceiveLoopAsync(ObsConnectionContext connection) { + CancellationToken cancellationToken = connection.ConnectionClosed; using MemoryStream bufferStream = new(); byte[] buffer = ArrayPool.Shared.Rent(ReceiveBufferSize); - IWebSocketConnection? currentWebSocket = _webSocket; + IWebSocketConnection currentWebSocket = connection.Transport; + + // Per connection, so a reload cannot move the ceiling mid-message. + int maxMessageBytes = Math.Max(1, _options.Value.MaxIncomingMessageBytes); try { - if (currentWebSocket is null || currentWebSocket.State != WebSocketState.Open) + if (currentWebSocket.State != WebSocketState.Open) { throw new InvalidOperationException( - $"Receive loop started with WebSocket not open or null. State: {currentWebSocket?.State}" + $"Receive loop started with WebSocket not open. State: {currentWebSocket.State}" ); } @@ -1298,10 +1330,20 @@ private async Task ReceiveLoopAsync(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); if (result.MessageType == WebSocketMessageType.Close) { - HandleServerClose(); + HandleServerClose(connection); return; } + // Before the write, so the growth the limit prevents never happens. + // Subtraction because the sum can overflow int. + if (result.Count > maxMessageBytes - bufferStream.Length) + { + throw new ObsWebSocketMessageTooLargeException( + maxMessageBytes, + bufferStream.Length + result.Count + ); + } + await bufferStream .WriteAsync(buffer.AsMemory(0, result.Count), cancellationToken) .ConfigureAwait(false); @@ -1316,21 +1358,31 @@ await bufferStream // Deserialize from a copy. The payload is parsed later, off this thread, and // the assembly buffer is reused by the next message, so anything pointing into // it would be reading the following message by then. - using MemoryStream messageStream = new(bufferStream.ToArray(), writable: false); - object? incomingMsgObj = await _serializer - .DeserializeAsync(messageStream, cancellationToken) + ReadOnlyMemory message = bufferStream.ToArray(); + object? incomingMsgObj = await connection + .Serializer.DeserializeAsync(message, cancellationToken) .ConfigureAwait(false); if (incomingMsgObj is null) { - _logger.LogDeserializationReturnedNullLength(messageStream.Length); + _logger.LogDeserializationReturnedNullLength(message.Length); + _metrics.MessagesDropped.Add( + 1, + new TagList { { "obsws.drop_reason", "undeserializable" } } + ); continue; } - ProcessIncomingMessage(incomingMsgObj); + ProcessIncomingMessage(connection, incomingMsgObj); } _logger.LogReceiveLoopExitingCancellationRequested(); } + catch (ObsWebSocketMessageTooLargeException ex) + { + // Unskippable: framing is past the point where the rest could be discarded safely. + _logger.LogIncomingMessageExceededLimit(ex.MaxBytes); + throw; + } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { _logger.LogReceiveLoopCancelledGracefullyViaToken(); @@ -1355,12 +1407,12 @@ await bufferStream } /// Handles server-initiated close frame. - private void HandleServerClose() + private void HandleServerClose(ObsConnectionContext connection) { - IWebSocketConnection? ws = _webSocket; + IWebSocketConnection ws = connection.Transport; bool expectedClosure = _clientLifetimeCts?.IsCancellationRequested ?? false; - string? desc = ws?.CloseStatusDescription; - WebSocketCloseStatus? status = ws?.CloseStatus; + string? desc = ws.CloseStatusDescription; + WebSocketCloseStatus? status = ws.CloseStatus; if (expectedClosure) { @@ -1376,7 +1428,7 @@ private void HandleServerClose() ); CleanupConnectionOnly(closeEx); - if (ws?.State == WebSocketState.CloseReceived) + if (ws.State == WebSocketState.CloseReceived) { _logger.LogAcknowledgingServerCloseFrame(); _ = Task.Run(async () => @@ -1411,55 +1463,31 @@ private void CleanupConnectionOnly(Exception reasonException) NegotiatedRpcVersion = null; CurrentEventSubscriptions = null; - CancellationTokenSource? receiveLoopCts = Interlocked.Exchange(ref _receiveCts, null); - CancellationToken tokenForFailure = - receiveLoopCts?.Token ?? _clientLifetimeCts?.Token ?? CancellationToken.None; - - _ = (_helloTcs?.TrySetException(reasonException)); - _helloTcs = null; - _ = (_identifiedTcs?.TrySetException(reasonException)); - _identifiedTcs = null; - - FailPendingRequests(_pendingRequests, reasonException, tokenForFailure); - FailPendingRequests(_pendingBatchRequests, reasonException, tokenForFailure); - - try - { - receiveLoopCts?.Cancel(); - } - catch (ObjectDisposedException) { } - catch (Exception ex) + ObsConnectionContext? connection; + using (_connectionLock.EnterScope()) { - _logger.LogExceptionCancellingReceiveCtsDuringCleanup(ex); + connection = _connection; + _connection = null; } - try - { - receiveLoopCts?.Dispose(); - } - catch (ObjectDisposedException) { } - catch (Exception ex) + CancellationToken tokenForFailure = + connection?.ConnectionClosed ?? _clientLifetimeCts?.Token ?? CancellationToken.None; + + if (connection is not null) { - _logger.LogExceptionDisposingReceiveCtsDuringCleanup(ex); + _ = connection.Hello.TrySetException(reasonException); + _ = connection.Identified.TrySetException(reasonException); } - _receiveTask = null; + FailPendingRequests(_pendingRequests, reasonException, tokenForFailure); + FailPendingRequests(_pendingBatchRequests, reasonException, tokenForFailure); - IWebSocketConnection? socket = Interlocked.Exchange(ref _webSocket, null); - if (socket != null) + if (connection is not null) { - _logger.LogDisposingWebsocketInstance(RuntimeHelpers.GetHashCode(socket)); - try - { - socket.Abort(); - } - catch { } + _logger.LogDisposingWebsocketInstance(RuntimeHelpers.GetHashCode(connection.Transport)); - try - { - socket.Dispose(); - } - catch { } + // Not DisposeAsync: a server-initiated close runs this on the receive loop itself. + connection.Close(); } } @@ -1525,11 +1553,19 @@ private Task FinalizeDisconnectionAsync( #region Message Processing private static readonly FrozenDictionary< string, - Action + Action > s_eventHandlers = InitializeEventHandlers().ToFrozenDictionary(); - private void ProcessIncomingMessage(object messageObject) + /// + /// Dispatches one message, using the serializer belonging to the connection it arrived on. + /// + /// + /// Threaded through rather than read from a field, so a reconnect onto a different format + /// cannot decode a message with the wrong serializer. + /// + private void ProcessIncomingMessage(ObsConnectionContext connection, object messageObject) { + IWebSocketMessageSerializer serializer = connection.Serializer; WebSocketOpCode opCode; object? payloadData; switch (messageObject) @@ -1551,20 +1587,20 @@ private void ProcessIncomingMessage(object messageObject) { case WebSocketOpCode.Hello: _logger.LogProcessingHelloMessage(); - _ = (_helloTcs?.TrySetResult(messageObject)); + _ = connection.Hello.TrySetResult(messageObject); break; case WebSocketOpCode.Identified: _logger.LogProcessingIdentifiedMessage(); - _ = (_identifiedTcs?.TrySetResult(messageObject)); + _ = connection.Identified.TrySetResult(messageObject); break; case WebSocketOpCode.Event: - HandleEventMessage(payloadData); + HandleEventMessage(serializer, payloadData); break; case WebSocketOpCode.RequestResponse: - HandleRequestResponseMessage(payloadData); + HandleRequestResponseMessage(serializer, payloadData); break; case WebSocketOpCode.RequestBatchResponse: - HandleRequestBatchResponseMessage(payloadData); + HandleRequestBatchResponseMessage(serializer, payloadData); break; default: _logger.LogReceivedMessageWithUnhandledOpcode(opCode); @@ -1572,7 +1608,10 @@ private void ProcessIncomingMessage(object messageObject) } } - private void HandleRequestResponseMessage(object? payloadData) + private void HandleRequestResponseMessage( + IWebSocketMessageSerializer serializer, + object? payloadData + ) { if (payloadData == null) { @@ -1586,7 +1625,7 @@ private void HandleRequestResponseMessage(object? payloadData) // so there is no pending request to fault. The awaiting caller times out instead, // and this log is the only record of why. if ( - !_serializer.TryDeserializePayload( + !serializer.TryDeserializePayload( payloadData, out RequestResponsePayload? response ) || response is null @@ -1623,7 +1662,10 @@ out TaskCompletionSource? tcs } } - private void HandleRequestBatchResponseMessage(object? payloadData) + private void HandleRequestBatchResponseMessage( + IWebSocketMessageSerializer serializer, + object? payloadData + ) { if (payloadData == null) { @@ -1634,7 +1676,7 @@ private void HandleRequestBatchResponseMessage(object? payloadData) try { if ( - !_serializer.TryDeserializePayload( + !serializer.TryDeserializePayload( payloadData, out RequestBatchResponsePayload? response ) || response is null @@ -1671,7 +1713,7 @@ out TaskCompletionSource? tcs } } - private void HandleEventMessage(object? payloadData) + private void HandleEventMessage(IWebSocketMessageSerializer serializer, object? payloadData) { if (payloadData == null) { @@ -1685,7 +1727,7 @@ private void HandleEventMessage(object? payloadData) // Tolerant on purpose: a newer OBS sending an event this build cannot model must not // tear the connection down. if ( - !_serializer.TryDeserializePayload(payloadData, out eventPayloadBase) + !serializer.TryDeserializePayload(payloadData, out eventPayloadBase) || eventPayloadBase is null ) { @@ -1697,13 +1739,17 @@ private void HandleEventMessage(object? payloadData) if ( s_eventHandlers.TryGetValue( eventPayloadBase.EventType, - out Action? handlerAction + out Action< + ObsWebSocketClient, + IWebSocketMessageSerializer, + object? + >? handlerAction ) ) { try { - handlerAction(this, eventPayloadBase.EventData); + handlerAction(this, serializer, eventPayloadBase.EventData); } catch (Exception ex) { @@ -1739,13 +1785,14 @@ private void HandleEventMessage(object? payloadData) /// than nesting it under that name. Deserializing into the generated record therefore looks /// one level too deep and always yields null, so the raw element is taken as the payload. /// + /// The serializer belonging to the connection this arrived on. /// The raw event data payload, as produced by the active serializer. - private void HandleCustomEvent(object? rawData) + private void HandleCustomEvent(IWebSocketMessageSerializer serializer, object? rawData) { try { if ( - !_serializer.TryDeserializeValuePayload(rawData, out JsonElement? broadcastData) + !serializer.TryDeserializeValuePayload(rawData, out JsonElement? broadcastData) || broadcastData is null ) { @@ -1762,6 +1809,7 @@ private void HandleCustomEvent(object? rawData) } private void TryHandleEvent( + IWebSocketMessageSerializer serializer, string eventType, object? rawData, Func argsFactory, @@ -1772,7 +1820,7 @@ Action invoker { try { - _ = _serializer.TryDeserializePayload(rawData, out TPayload? payload); + _ = serializer.TryDeserializePayload(rawData, out TPayload? payload); if (payload is not null) { _metrics.EventsReceived.Add(1, new TagList { { "obsws.event_type", eventType } }); @@ -1791,387 +1839,451 @@ Action invoker private static Dictionary< string, - Action + Action > InitializeEventHandlers() => new(StringComparer.Ordinal) { // Config - ["CurrentSceneCollectionChanging"] = (c, d) => + ["CurrentSceneCollectionChanging"] = (c, s, d) => c.TryHandleEvent< CurrentSceneCollectionChangingPayload, CurrentSceneCollectionChangingEventArgs >( + s, "CurrentSceneCollectionChanging", d, p => new(p), c.OnCurrentSceneCollectionChanging ), - ["CurrentSceneCollectionChanged"] = (c, d) => + ["CurrentSceneCollectionChanged"] = (c, s, d) => c.TryHandleEvent< CurrentSceneCollectionChangedPayload, CurrentSceneCollectionChangedEventArgs >( + s, "CurrentSceneCollectionChanged", d, p => new(p), c.OnCurrentSceneCollectionChanged ), - ["SceneCollectionListChanged"] = (c, d) => + ["SceneCollectionListChanged"] = (c, s, d) => c.TryHandleEvent< SceneCollectionListChangedPayload, SceneCollectionListChangedEventArgs - >("SceneCollectionListChanged", d, p => new(p), c.OnSceneCollectionListChanged), - ["CurrentProfileChanging"] = (c, d) => + >(s, "SceneCollectionListChanged", d, p => new(p), c.OnSceneCollectionListChanged), + ["CurrentProfileChanging"] = (c, s, d) => c.TryHandleEvent( + s, "CurrentProfileChanging", d, p => new(p), c.OnCurrentProfileChanging ), - ["CurrentProfileChanged"] = (c, d) => + ["CurrentProfileChanged"] = (c, s, d) => c.TryHandleEvent( + s, "CurrentProfileChanged", d, p => new(p), c.OnCurrentProfileChanged ), - ["ProfileListChanged"] = (c, d) => + ["ProfileListChanged"] = (c, s, d) => c.TryHandleEvent( + s, "ProfileListChanged", d, p => new(p), c.OnProfileListChanged ), // Filters - ["SourceFilterListReindexed"] = (c, d) => + ["SourceFilterListReindexed"] = (c, s, d) => c.TryHandleEvent< SourceFilterListReindexedPayload, SourceFilterListReindexedEventArgs - >("SourceFilterListReindexed", d, p => new(p), c.OnSourceFilterListReindexed), - ["SourceFilterCreated"] = (c, d) => + >(s, "SourceFilterListReindexed", d, p => new(p), c.OnSourceFilterListReindexed), + ["SourceFilterCreated"] = (c, s, d) => c.TryHandleEvent( + s, "SourceFilterCreated", d, p => new(p), c.OnSourceFilterCreated ), - ["SourceFilterRemoved"] = (c, d) => + ["SourceFilterRemoved"] = (c, s, d) => c.TryHandleEvent( + s, "SourceFilterRemoved", d, p => new(p), c.OnSourceFilterRemoved ), - ["SourceFilterNameChanged"] = (c, d) => + ["SourceFilterNameChanged"] = (c, s, d) => c.TryHandleEvent( + s, "SourceFilterNameChanged", d, p => new(p), c.OnSourceFilterNameChanged ), - ["SourceFilterSettingsChanged"] = (c, d) => + ["SourceFilterSettingsChanged"] = (c, s, d) => c.TryHandleEvent< SourceFilterSettingsChangedPayload, SourceFilterSettingsChangedEventArgs - >("SourceFilterSettingsChanged", d, p => new(p), c.OnSourceFilterSettingsChanged), - ["SourceFilterEnableStateChanged"] = (c, d) => + >( + s, + "SourceFilterSettingsChanged", + d, + p => new(p), + c.OnSourceFilterSettingsChanged + ), + ["SourceFilterEnableStateChanged"] = (c, s, d) => c.TryHandleEvent< SourceFilterEnableStateChangedPayload, SourceFilterEnableStateChangedEventArgs >( + s, "SourceFilterEnableStateChanged", d, p => new(p), c.OnSourceFilterEnableStateChanged ), // General - ["ExitStarted"] = (c, d) => c.OnExitStarted(new ExitStartedEventArgs()), - ["VendorEvent"] = (c, d) => + ["ExitStarted"] = (c, _, d) => c.OnExitStarted(new ExitStartedEventArgs()), + ["VendorEvent"] = (c, s, d) => c.TryHandleEvent( + s, "VendorEvent", d, p => new(p), c.OnVendorEvent ), - ["CustomEvent"] = (c, d) => c.HandleCustomEvent(d), + ["CustomEvent"] = (c, s, d) => c.HandleCustomEvent(s, d), // Inputs - ["InputCreated"] = (c, d) => + ["InputCreated"] = (c, s, d) => c.TryHandleEvent( + s, "InputCreated", d, p => new(p), c.OnInputCreated ), - ["InputRemoved"] = (c, d) => + ["InputRemoved"] = (c, s, d) => c.TryHandleEvent( + s, "InputRemoved", d, p => new(p), c.OnInputRemoved ), - ["InputNameChanged"] = (c, d) => + ["InputNameChanged"] = (c, s, d) => c.TryHandleEvent( + s, "InputNameChanged", d, p => new(p), c.OnInputNameChanged ), - ["InputSettingsChanged"] = (c, d) => + ["InputSettingsChanged"] = (c, s, d) => c.TryHandleEvent( + s, "InputSettingsChanged", d, p => new(p), c.OnInputSettingsChanged ), - ["InputActiveStateChanged"] = (c, d) => + ["InputActiveStateChanged"] = (c, s, d) => c.TryHandleEvent( + s, "InputActiveStateChanged", d, p => new(p), c.OnInputActiveStateChanged ), - ["InputShowStateChanged"] = (c, d) => + ["InputShowStateChanged"] = (c, s, d) => c.TryHandleEvent( + s, "InputShowStateChanged", d, p => new(p), c.OnInputShowStateChanged ), - ["InputMuteStateChanged"] = (c, d) => + ["InputMuteStateChanged"] = (c, s, d) => c.TryHandleEvent( + s, "InputMuteStateChanged", d, p => new(p), c.OnInputMuteStateChanged ), - ["InputVolumeChanged"] = (c, d) => + ["InputVolumeChanged"] = (c, s, d) => c.TryHandleEvent( + s, "InputVolumeChanged", d, p => new(p), c.OnInputVolumeChanged ), - ["InputAudioBalanceChanged"] = (c, d) => + ["InputAudioBalanceChanged"] = (c, s, d) => c.TryHandleEvent< InputAudioBalanceChangedPayload, InputAudioBalanceChangedEventArgs - >("InputAudioBalanceChanged", d, p => new(p), c.OnInputAudioBalanceChanged), - ["InputAudioSyncOffsetChanged"] = (c, d) => + >(s, "InputAudioBalanceChanged", d, p => new(p), c.OnInputAudioBalanceChanged), + ["InputAudioSyncOffsetChanged"] = (c, s, d) => c.TryHandleEvent< InputAudioSyncOffsetChangedPayload, InputAudioSyncOffsetChangedEventArgs - >("InputAudioSyncOffsetChanged", d, p => new(p), c.OnInputAudioSyncOffsetChanged), - ["InputAudioTracksChanged"] = (c, d) => + >( + s, + "InputAudioSyncOffsetChanged", + d, + p => new(p), + c.OnInputAudioSyncOffsetChanged + ), + ["InputAudioTracksChanged"] = (c, s, d) => c.TryHandleEvent( + s, "InputAudioTracksChanged", d, p => new(p), c.OnInputAudioTracksChanged ), - ["InputAudioMonitorTypeChanged"] = (c, d) => + ["InputAudioMonitorTypeChanged"] = (c, s, d) => c.TryHandleEvent< InputAudioMonitorTypeChangedPayload, InputAudioMonitorTypeChangedEventArgs - >("InputAudioMonitorTypeChanged", d, p => new(p), c.OnInputAudioMonitorTypeChanged), - ["InputVolumeMeters"] = (c, d) => + >( + s, + "InputAudioMonitorTypeChanged", + d, + p => new(p), + c.OnInputAudioMonitorTypeChanged + ), + ["InputVolumeMeters"] = (c, s, d) => c.TryHandleEvent( + s, "InputVolumeMeters", d, p => new(p), c.OnInputVolumeMeters ), // Media Inputs - ["MediaInputPlaybackStarted"] = (c, d) => + ["MediaInputPlaybackStarted"] = (c, s, d) => c.TryHandleEvent< MediaInputPlaybackStartedPayload, MediaInputPlaybackStartedEventArgs - >("MediaInputPlaybackStarted", d, p => new(p), c.OnMediaInputPlaybackStarted), - ["MediaInputPlaybackEnded"] = (c, d) => + >(s, "MediaInputPlaybackStarted", d, p => new(p), c.OnMediaInputPlaybackStarted), + ["MediaInputPlaybackEnded"] = (c, s, d) => c.TryHandleEvent( + s, "MediaInputPlaybackEnded", d, p => new(p), c.OnMediaInputPlaybackEnded ), - ["MediaInputActionTriggered"] = (c, d) => + ["MediaInputActionTriggered"] = (c, s, d) => c.TryHandleEvent< MediaInputActionTriggeredPayload, MediaInputActionTriggeredEventArgs - >("MediaInputActionTriggered", d, p => new(p), c.OnMediaInputActionTriggered), + >(s, "MediaInputActionTriggered", d, p => new(p), c.OnMediaInputActionTriggered), // Outputs - ["StreamStateChanged"] = (c, d) => + ["StreamStateChanged"] = (c, s, d) => c.TryHandleEvent( + s, "StreamStateChanged", d, p => new(p), c.OnStreamStateChanged ), - ["RecordStateChanged"] = (c, d) => + ["RecordStateChanged"] = (c, s, d) => c.TryHandleEvent( + s, "RecordStateChanged", d, p => new(p), c.OnRecordStateChanged ), - ["RecordFileChanged"] = (c, d) => + ["RecordFileChanged"] = (c, s, d) => c.TryHandleEvent( + s, "RecordFileChanged", d, p => new(p), c.OnRecordFileChanged ), - ["ReplayBufferStateChanged"] = (c, d) => + ["ReplayBufferStateChanged"] = (c, s, d) => c.TryHandleEvent< ReplayBufferStateChangedPayload, ReplayBufferStateChangedEventArgs - >("ReplayBufferStateChanged", d, p => new(p), c.OnReplayBufferStateChanged), - ["VirtualcamStateChanged"] = (c, d) => + >(s, "ReplayBufferStateChanged", d, p => new(p), c.OnReplayBufferStateChanged), + ["VirtualcamStateChanged"] = (c, s, d) => c.TryHandleEvent( + s, "VirtualcamStateChanged", d, p => new(p), c.OnVirtualcamStateChanged ), - ["ReplayBufferSaved"] = (c, d) => + ["ReplayBufferSaved"] = (c, s, d) => c.TryHandleEvent( + s, "ReplayBufferSaved", d, p => new(p), c.OnReplayBufferSaved ), // Scene Items - ["SceneItemCreated"] = (c, d) => + ["SceneItemCreated"] = (c, s, d) => c.TryHandleEvent( + s, "SceneItemCreated", d, p => new(p), c.OnSceneItemCreated ), - ["SceneItemRemoved"] = (c, d) => + ["SceneItemRemoved"] = (c, s, d) => c.TryHandleEvent( + s, "SceneItemRemoved", d, p => new(p), c.OnSceneItemRemoved ), - ["SceneItemListReindexed"] = (c, d) => + ["SceneItemListReindexed"] = (c, s, d) => c.TryHandleEvent( + s, "SceneItemListReindexed", d, p => new(p), c.OnSceneItemListReindexed ), - ["SceneItemEnableStateChanged"] = (c, d) => + ["SceneItemEnableStateChanged"] = (c, s, d) => c.TryHandleEvent< SceneItemEnableStateChangedPayload, SceneItemEnableStateChangedEventArgs - >("SceneItemEnableStateChanged", d, p => new(p), c.OnSceneItemEnableStateChanged), - ["SceneItemLockStateChanged"] = (c, d) => + >( + s, + "SceneItemEnableStateChanged", + d, + p => new(p), + c.OnSceneItemEnableStateChanged + ), + ["SceneItemLockStateChanged"] = (c, s, d) => c.TryHandleEvent< SceneItemLockStateChangedPayload, SceneItemLockStateChangedEventArgs - >("SceneItemLockStateChanged", d, p => new(p), c.OnSceneItemLockStateChanged), - ["SceneItemSelected"] = (c, d) => + >(s, "SceneItemLockStateChanged", d, p => new(p), c.OnSceneItemLockStateChanged), + ["SceneItemSelected"] = (c, s, d) => c.TryHandleEvent( + s, "SceneItemSelected", d, p => new(p), c.OnSceneItemSelected ), - ["SceneItemTransformChanged"] = (c, d) => + ["SceneItemTransformChanged"] = (c, s, d) => c.TryHandleEvent< SceneItemTransformChangedPayload, SceneItemTransformChangedEventArgs - >("SceneItemTransformChanged", d, p => new(p), c.OnSceneItemTransformChanged), + >(s, "SceneItemTransformChanged", d, p => new(p), c.OnSceneItemTransformChanged), // Scenes - ["SceneCreated"] = (c, d) => + ["SceneCreated"] = (c, s, d) => c.TryHandleEvent( + s, "SceneCreated", d, p => new(p), c.OnSceneCreated ), - ["SceneRemoved"] = (c, d) => + ["SceneRemoved"] = (c, s, d) => c.TryHandleEvent( + s, "SceneRemoved", d, p => new(p), c.OnSceneRemoved ), - ["SceneNameChanged"] = (c, d) => + ["SceneNameChanged"] = (c, s, d) => c.TryHandleEvent( + s, "SceneNameChanged", d, p => new(p), c.OnSceneNameChanged ), - ["CurrentProgramSceneChanged"] = (c, d) => + ["CurrentProgramSceneChanged"] = (c, s, d) => c.TryHandleEvent< CurrentProgramSceneChangedPayload, CurrentProgramSceneChangedEventArgs - >("CurrentProgramSceneChanged", d, p => new(p), c.OnCurrentProgramSceneChanged), - ["CurrentPreviewSceneChanged"] = (c, d) => + >(s, "CurrentProgramSceneChanged", d, p => new(p), c.OnCurrentProgramSceneChanged), + ["CurrentPreviewSceneChanged"] = (c, s, d) => c.TryHandleEvent< CurrentPreviewSceneChangedPayload, CurrentPreviewSceneChangedEventArgs - >("CurrentPreviewSceneChanged", d, p => new(p), c.OnCurrentPreviewSceneChanged), - ["SceneListChanged"] = (c, d) => + >(s, "CurrentPreviewSceneChanged", d, p => new(p), c.OnCurrentPreviewSceneChanged), + ["SceneListChanged"] = (c, s, d) => c.TryHandleEvent( + s, "SceneListChanged", d, p => new(p), c.OnSceneListChanged ), // Transitions - ["CurrentSceneTransitionChanged"] = (c, d) => + ["CurrentSceneTransitionChanged"] = (c, s, d) => c.TryHandleEvent< CurrentSceneTransitionChangedPayload, CurrentSceneTransitionChangedEventArgs >( + s, "CurrentSceneTransitionChanged", d, p => new(p), c.OnCurrentSceneTransitionChanged ), - ["CurrentSceneTransitionDurationChanged"] = (c, d) => + ["CurrentSceneTransitionDurationChanged"] = (c, s, d) => c.TryHandleEvent< CurrentSceneTransitionDurationChangedPayload, CurrentSceneTransitionDurationChangedEventArgs >( + s, "CurrentSceneTransitionDurationChanged", d, p => new(p), c.OnCurrentSceneTransitionDurationChanged ), - ["SceneTransitionStarted"] = (c, d) => + ["SceneTransitionStarted"] = (c, s, d) => c.TryHandleEvent( + s, "SceneTransitionStarted", d, p => new(p), c.OnSceneTransitionStarted ), - ["SceneTransitionEnded"] = (c, d) => + ["SceneTransitionEnded"] = (c, s, d) => c.TryHandleEvent( + s, "SceneTransitionEnded", d, p => new(p), c.OnSceneTransitionEnded ), - ["SceneTransitionVideoEnded"] = (c, d) => + ["SceneTransitionVideoEnded"] = (c, s, d) => c.TryHandleEvent< SceneTransitionVideoEndedPayload, SceneTransitionVideoEndedEventArgs - >("SceneTransitionVideoEnded", d, p => new(p), c.OnSceneTransitionVideoEnded), + >(s, "SceneTransitionVideoEnded", d, p => new(p), c.OnSceneTransitionVideoEnded), // UI - ["StudioModeStateChanged"] = (c, d) => + ["StudioModeStateChanged"] = (c, s, d) => c.TryHandleEvent( + s, "StudioModeStateChanged", d, p => new(p), c.OnStudioModeStateChanged ), - ["ScreenshotSaved"] = (c, d) => + ["ScreenshotSaved"] = (c, s, d) => c.TryHandleEvent( + s, "ScreenshotSaved", d, p => new(p), @@ -2182,20 +2294,34 @@ private static Dictionary< #region Helper Methods (Static & Instance) [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void EnsureConnected() + internal void EnsureConnected() => _ = RequireConnection(); + + /// + /// Returns the live connection, or explains that there is not one. + /// + /// + /// Returned so a caller acts on one connection for the whole operation. + /// + /// Thrown when the client is not connected. + private ObsConnectionContext RequireConnection() { - if ( + ObsConnectionContext? connection = _connection; + return _connectionState != ConnectionState.Connected - || _webSocket?.State != WebSocketState.Open - ) - { - throw new InvalidOperationException( - $"Client is not connected (State: {_connectionState}, Socket: {_webSocket?.State})." - ); - } + || connection is null + || connection.Transport.State != WebSocketState.Open + ? throw new InvalidOperationException( + $"Client is not connected (State: {_connectionState}, " + + $"Socket: {connection?.Transport.State.ToString() ?? "none"})." + ) + : connection; } - private TPayload ExtractPayloadFromHandshake(object messageObject, string messageName) + private static TPayload ExtractPayloadFromHandshake( + ObsConnectionContext connection, + object messageObject, + string messageName + ) where TPayload : class { object? rawPayload = messageObject switch @@ -2206,7 +2332,7 @@ private TPayload ExtractPayloadFromHandshake(object messageObject, str $"Unexpected message type during {messageName} handshake: {messageObject.GetType().Name}" ), }; - TPayload? specificPayload = _serializer.DeserializePayload(rawPayload); + TPayload? specificPayload = connection.Serializer.DeserializePayload(rawPayload); return specificPayload ?? throw new ObsWebSocketException($"Received null or invalid {messageName} payload."); } @@ -2472,8 +2598,14 @@ private async Task SendMessageAsync( CancellationToken linkedToken ) { - IWebSocketConnection? currentWebSocket = _webSocket; - if (currentWebSocket is null || currentWebSocket.State != WebSocketState.Open) + // Encoded for the socket it goes out on, not the most recently configured format. + ObsConnectionContext? connection = _connection; + IWebSocketConnection? currentWebSocket = connection?.Transport; + if ( + connection is null + || currentWebSocket is null + || currentWebSocket.State != WebSocketState.Open + ) { throw new InvalidOperationException( $"Cannot send '{opCode}', WebSocket not open or available (State: {currentWebSocket?.State})." @@ -2488,8 +2620,8 @@ CancellationToken linkedToken byte[] messageBytes; try { - messageBytes = await _serializer - .SerializeAsync(message, linkedToken) + messageBytes = await connection + .Serializer.SerializeAsync(message, linkedToken) .ConfigureAwait(false); } catch (Exception ex) @@ -2498,7 +2630,7 @@ CancellationToken linkedToken throw new ObsWebSocketException($"Serialization failed for {opCode}.", ex); } - WebSocketMessageType messageType = _serializer.ProtocolSubProtocol.Contains( + WebSocketMessageType messageType = connection.Serializer.ProtocolSubProtocol.Contains( "json", StringComparison.OrdinalIgnoreCase ) @@ -2619,7 +2751,7 @@ private void RaiseAuthenticationFailureEvent(Uri uri, int attempt, Exception err { if ( _connectionState != ConnectionState.Disconnected - || _webSocket != null + || _connection != null || _clientLifetimeCts != null ) { @@ -2638,13 +2770,7 @@ private void RaiseAuthenticationFailureEvent(Uri uri, int attempt, Exception err try { - _webSocket?.Abort(); - } - catch { } - - try - { - _webSocket?.Dispose(); + _connection?.Close(); } catch { } } diff --git a/ObsWebSocket.Core/ObsWebSocketClientLog.cs b/ObsWebSocket.Core/ObsWebSocketClientLog.cs index 63d970d..e7a3cee 100644 --- a/ObsWebSocket.Core/ObsWebSocketClientLog.cs +++ b/ObsWebSocket.Core/ObsWebSocketClientLog.cs @@ -484,6 +484,13 @@ WebSocketState webSocketState [LoggerMessage(EventId = 52, Level = LogLevel.Trace, Message = "Received empty message.")] public static partial void LogReceivedEmptyMessage(this ILogger logger); + [LoggerMessage( + EventId = 100, + Level = LogLevel.Error, + Message = "An incoming message exceeded the {MaxBytes} byte limit; closing the connection." + )] + public static partial void LogIncomingMessageExceededLimit(this ILogger logger, int maxBytes); + [LoggerMessage( EventId = 53, Level = LogLevel.Warning, diff --git a/ObsWebSocket.Core/ObsWebSocketClientOptions.cs b/ObsWebSocket.Core/ObsWebSocketClientOptions.cs index c210e06..67ffd34 100644 --- a/ObsWebSocket.Core/ObsWebSocketClientOptions.cs +++ b/ObsWebSocket.Core/ObsWebSocketClientOptions.cs @@ -73,4 +73,25 @@ public sealed class ObsWebSocketClientOptions /// Defaults to 60000ms (1 minute). /// public int MaxReconnectDelayMs { get; set; } = 60000; + + /// + /// Gets or sets the largest inbound message the client will assemble, in bytes. + /// Defaults to (64 MiB). + /// + /// + /// + /// A WebSocket message arrives as any number of fragments and its size is only known once the + /// last one has been read, so without a ceiling the client will assemble whatever it is sent. + /// The receive loop stops before appending the fragment that would cross this limit, and the + /// connection fails with instead of growing. + /// + /// + /// The default is generous because legitimate responses are large: a + /// GetSourceScreenshot of a 4K canvas is a base64 data URI of several megabytes, and a + /// big scene collection's item list is not small either. Size it to the largest response the + /// application actually asks OBS for, not to the receive buffer. + /// + /// + public int MaxIncomingMessageBytes { get; set; } = + ObsWebSocketClient.DefaultMaxIncomingMessageBytes; } diff --git a/ObsWebSocket.Core/ObsWebSocketClientOptionsValidator.cs b/ObsWebSocket.Core/ObsWebSocketClientOptionsValidator.cs index 07cef4a..803b00c 100644 --- a/ObsWebSocket.Core/ObsWebSocketClientOptionsValidator.cs +++ b/ObsWebSocket.Core/ObsWebSocketClientOptionsValidator.cs @@ -55,6 +55,17 @@ public ValidateOptionsResult Validate(string? name, ObsWebSocketClientOptions op failures.Add("ReconnectBackoffMultiplier must be at least 1.0."); } + // Rejected here rather than clamped at the receive loop, so a limit too small to carry a + // real response is reported as misconfiguration instead of as a connection that keeps + // dying on the first screenshot. + if (options.MaxIncomingMessageBytes < ObsWebSocketClient.ReceiveBufferSize) + { + failures.Add( + "MaxIncomingMessageBytes must be at least the " + + $"{ObsWebSocketClient.ReceiveBufferSize} byte receive buffer." + ); + } + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); diff --git a/ObsWebSocket.Core/ObsWebSocketHosting.cs b/ObsWebSocket.Core/ObsWebSocketHosting.cs index 2f29c00..195b955 100644 --- a/ObsWebSocket.Core/ObsWebSocketHosting.cs +++ b/ObsWebSocket.Core/ObsWebSocketHosting.cs @@ -1,9 +1,11 @@ +using System.Threading.Channels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ObsWebSocket.Core.Networking; namespace ObsWebSocket.Core; @@ -21,14 +23,31 @@ internal sealed class ObsWebSocketConnectionService( string? name = null ) : IHostedService, IDisposable { + private readonly CancellationTokenSource _stopping = new(); + + /// Pending configuration change; only the newest matters. + private readonly Channel _pending = + Channel.CreateBounded( + new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + } + ); + private IDisposable? _optionsWatch; - private (Uri? Uri, string? Password, SerializationFormat Format) _connectedWith; + private Task? _transitions; + private ObsConnectionSettings? _connectedWith; private string OptionsName => name ?? Options.DefaultName; /// public async Task StartAsync(CancellationToken cancellationToken) { + // Before the watch, so a change during startup is not dropped. + _transitions = Task.Run(ProcessTransitionsAsync, CancellationToken.None); + _optionsWatch = options.OnChange( (updated, changedName) => { @@ -50,8 +69,7 @@ public async Task StartAsync(CancellationToken cancellationToken) private async Task ConnectAsync(CancellationToken cancellationToken) { - ObsWebSocketClientOptions current = options.Get(OptionsName); - _connectedWith = (current.ServerUri, current.Password, current.Format); + _connectedWith = ObsConnectionSettings.Capture(options.Get(OptionsName)); try { @@ -68,17 +86,10 @@ private async Task ConnectAsync(CancellationToken cancellationToken) } } - /// - /// Reconnects when the endpoint changes. Timeouts and reconnect settings are read per call, - /// so only the things fixed at connection time are worth cycling the connection for. - /// + /// Queues a reconnect when something the connection is built from changes. private void OnOptionsChanged(ObsWebSocketClientOptions updated) { - if ( - updated.ServerUri == _connectedWith.Uri - && updated.Password == _connectedWith.Password - && updated.Format == _connectedWith.Format - ) + if (updated.ServerUri is null || _connectedWith?.RequiresNewConnection(updated) is not true) { return; } @@ -88,30 +99,64 @@ private void OnOptionsChanged(ObsWebSocketClientOptions updated) updated.ServerUri ); - _ = Task.Run(async () => + _ = _pending.Writer.TryWrite(ObsConnectionSettings.Capture(updated)); + } + + /// Applies queued configuration changes, one at a time, until the host stops. + /// One reader, awaited by , so transitions cannot overlap or + /// outlive shutdown. + private async Task ProcessTransitionsAsync() + { + try { - try + await foreach ( + ObsConnectionSettings _ in _pending + .Reader.ReadAllAsync(_stopping.Token) + .ConfigureAwait(false) + ) { - if (client.IsConnected) + try { await client.DisconnectAsync().ConfigureAwait(false); + await ConnectAsync(_stopping.Token).ConfigureAwait(false); + } + catch (Exception ex) + when (ex is ObsWebSocketException or OperationCanceledException) + { + logger.LogWarning(ex, "Reconnect after a settings change did not succeed."); } - - await ConnectAsync(CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) when (ex is ObsWebSocketException or OperationCanceledException) - { - logger.LogWarning(ex, "Reconnect after a settings change did not succeed."); } - }); + } + catch (OperationCanceledException) + { + // The host is stopping. + } } /// - public void Dispose() => _optionsWatch?.Dispose(); + public void Dispose() + { + _optionsWatch?.Dispose(); + _stopping.Dispose(); + } /// public async Task StopAsync(CancellationToken cancellationToken) { + // Stop accepting changes before draining. + _optionsWatch?.Dispose(); + _optionsWatch = null; + _ = _pending.Writer.TryComplete(); + + await _stopping.CancelAsync().ConfigureAwait(false); + + if (_transitions is { } transitions) + { + // Awaited so an in-flight reconnect cannot outlive shutdown. + await transitions.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + _transitions = null; + } + if (client.IsConnected) { await client diff --git a/ObsWebSocket.Core/ObsWebSocketMessageTooLargeException.cs b/ObsWebSocket.Core/ObsWebSocketMessageTooLargeException.cs new file mode 100644 index 0000000..ab7a185 --- /dev/null +++ b/ObsWebSocket.Core/ObsWebSocketMessageTooLargeException.cs @@ -0,0 +1,49 @@ +namespace ObsWebSocket.Core; + +/// +/// Thrown when an inbound message exceeds +/// . +/// +/// +/// Ends the connection rather than the single message. By the time the limit is reached the +/// remaining fragments of that message cannot be skipped without also losing track of where the +/// next message begins, and a peer that sends one is not one to keep reading from. +/// +public sealed class ObsWebSocketMessageTooLargeException : ObsWebSocketException +{ + /// Initializes the exception for a message that crossed the limit. + /// The configured ceiling. + /// The size the message had reached when it was stopped. + public ObsWebSocketMessageTooLargeException(int maxBytes, long attemptedBytes) + : base( + $"An incoming message reached {attemptedBytes} bytes, past the " + + $"{maxBytes} byte limit set by {nameof(ObsWebSocketClientOptions)}." + + $"{nameof(ObsWebSocketClientOptions.MaxIncomingMessageBytes)}. " + + "Raise the limit if this endpoint legitimately sends messages this large." + ) + { + MaxBytes = maxBytes; + AttemptedBytes = attemptedBytes; + } + + /// Initializes the exception with a message. + /// The message describing the failure. + public ObsWebSocketMessageTooLargeException(string message) + : base(message) { } + + /// Initializes the exception with a message and inner exception. + /// The message describing the failure. + /// The underlying cause. + public ObsWebSocketMessageTooLargeException(string message, Exception innerException) + : base(message, innerException) { } + + /// Initializes the exception. + public ObsWebSocketMessageTooLargeException() + : base("An incoming message exceeded the configured size limit.") { } + + /// The configured ceiling, in bytes. + public int MaxBytes { get; } + + /// How large the message had grown when it was stopped, in bytes. + public long AttemptedBytes { get; } +} diff --git a/ObsWebSocket.Core/ObsWebSocketMetrics.cs b/ObsWebSocket.Core/ObsWebSocketMetrics.cs index 3b939d7..e3f5b37 100644 --- a/ObsWebSocket.Core/ObsWebSocketMetrics.cs +++ b/ObsWebSocket.Core/ObsWebSocketMetrics.cs @@ -22,18 +22,30 @@ public ObsWebSocketMetrics(IMeterFactory meterFactory) ArgumentNullException.ThrowIfNull(meterFactory); _meter = meterFactory.Create(ObsWebSocketDiagnostics.MeterName); _ownsMeter = false; - (RequestsSent, RequestsFailed, RequestDuration, EventsReceived, Reconnects) = Create( - _meter - ); + ( + RequestsSent, + RequestsFailed, + RequestDuration, + EventsReceived, + Reconnects, + EventsDropped, + MessagesDropped + ) = Create(_meter); } private ObsWebSocketMetrics() { _meter = new Meter(ObsWebSocketDiagnostics.MeterName); _ownsMeter = true; - (RequestsSent, RequestsFailed, RequestDuration, EventsReceived, Reconnects) = Create( - _meter - ); + ( + RequestsSent, + RequestsFailed, + RequestDuration, + EventsReceived, + Reconnects, + EventsDropped, + MessagesDropped + ) = Create(_meter); } /// Instruments for a client built outside dependency injection. @@ -54,6 +66,25 @@ private ObsWebSocketMetrics() /// Reconnection attempts. public Counter Reconnects { get; } + /// + /// Events dropped because a stream's consumer fell behind, tagged by event type. + /// + /// + /// Streams drop the oldest event when full so a slow consumer cannot stall the receive loop. + /// Without this counter the only evidence is an event that never arrived. + /// + public Counter EventsDropped { get; } + + /// + /// Inbound messages discarded without being dispatched, tagged by reason. + /// + /// + /// The receive loop tolerates a message it cannot read, so a newer OBS cannot tear the + /// connection down. A malformed frame is indistinguishable from a forward-compatible one, so + /// this separates a quiet connection from one that is discarding traffic. + /// + public Counter MessagesDropped { get; } + /// public void Dispose() { @@ -68,7 +99,9 @@ private static ( Counter Failed, Histogram Duration, Counter Events, - Counter Reconnects + Counter Reconnects, + Counter EventsDropped, + Counter MessagesDropped ) Create(Meter meter) => ( meter.CreateCounter( @@ -95,6 +128,16 @@ Counter Reconnects "obsws.reconnects", unit: "{attempt}", description: "Reconnection attempts." + ), + meter.CreateCounter( + "obsws.events.dropped", + unit: "{event}", + description: "Events dropped because an event stream's consumer fell behind." + ), + meter.CreateCounter( + "obsws.messages.dropped", + unit: "{message}", + description: "Inbound messages discarded without being dispatched." ) ); } diff --git a/ObsWebSocket.Core/ObsWebSocketResilience.cs b/ObsWebSocket.Core/ObsWebSocketResilience.cs index ae20e72..64becde 100644 --- a/ObsWebSocket.Core/ObsWebSocketResilience.cs +++ b/ObsWebSocket.Core/ObsWebSocketResilience.cs @@ -57,11 +57,32 @@ this IServiceCollection services internal static RetryStrategyOptions CreateRetryOptions(ObsWebSocketClientOptions options) { ArgumentNullException.ThrowIfNull(options); + return CreateRetryOptions( + options.ReconnectBackoffMultiplier, + options.InitialReconnectDelayMs, + options.MaxReconnectDelayMs + ); + } - double multiplier = - options.ReconnectBackoffMultiplier > 1.0 ? options.ReconnectBackoffMultiplier : 1.0; - double initialMs = options.InitialReconnectDelayMs; - double maxMs = Math.Max(options.MaxReconnectDelayMs, initialMs); + /// + /// Builds the retry strategy from the three values that describe the backoff curve. + /// + /// + /// Taken as values rather than as an options object so that a live connection can build its + /// strategy from the settings it was established with, which do not change underneath it. + /// + /// Growth applied per attempt. + /// Delay before the first retry. + /// Ceiling on the delay. + internal static RetryStrategyOptions CreateRetryOptions( + double backoffMultiplier, + int initialDelayMs, + int maxDelayMs + ) + { + double multiplier = backoffMultiplier > 1.0 ? backoffMultiplier : 1.0; + double initialMs = initialDelayMs; + double maxMs = Math.Max(maxDelayMs, initialMs); return new RetryStrategyOptions { diff --git a/ObsWebSocket.Core/ObsWebSocketServiceCollectionExtensions.cs b/ObsWebSocket.Core/ObsWebSocketServiceCollectionExtensions.cs index bd7c019..c221d55 100644 --- a/ObsWebSocket.Core/ObsWebSocketServiceCollectionExtensions.cs +++ b/ObsWebSocket.Core/ObsWebSocketServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -41,65 +41,51 @@ public static IObsWebSocketClientBuilder AddObsWebSocketClient( _ = optionsBuilder.Configure(configureOptions); } - services.TryAddSingleton(); - services.TryAddSingleton(); + AddSharedServices(services); - // This factory determines which concrete serializer to use based on options. + // For callers resolving a serializer directly; the client uses the factory per connection. _ = services.AddSingleton(sp => - { - ObsWebSocketClientOptions options = sp.GetRequiredService< - IOptions - >().Value; - return options.Format switch - { - SerializationFormat.MsgPack => sp.GetRequiredService(), - SerializationFormat.Json or _ => sp.GetRequiredService(), // Default to JSON - }; - }); - - services.TryAddSingleton(); - - services.TryAddSingleton(TimeProvider.System); + sp.GetRequiredService()( + sp.GetRequiredService>().Value.Format + ) + ); // Resolve options through the monitor, so a configuration change is picked up rather // than the values captured when the container was built. _ = services.AddSingleton>( sp => new MonitorBackedOptions( - sp.GetRequiredService>() + sp.GetRequiredService>(), + name: null ) ); - _ = services.AddMetrics(); - services.TryAddSingleton(); - services.TryAddSingleton(sp => - { - ILogger logger = sp.GetRequiredService< - ILogger - >(); - IWebSocketMessageSerializer serializer = - sp.GetRequiredService(); - IOptions options = sp.GetRequiredService< - IOptions - >(); - IWebSocketConnectionFactory factory = - sp.GetRequiredService(); - - TimeProvider timeProvider = sp.GetRequiredService(); - - // Pass dependencies to the constructor - return new ObsWebSocketClient( - logger, - serializer, - options, - factory, - timeProvider, - sp.GetRequiredService() - ); - }); + services.TryAddSingleton(sp => Create(sp, name: null)); return new ObsWebSocketClientBuilder(services, name: null); } + /// Registers what every client needs, named or not. + /// Shared so the named and unnamed registration paths cannot drift. + private static void AddSharedServices(IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(TimeProvider.System); + _ = services.AddMetrics(); + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + format => + format switch + { + SerializationFormat.MsgPack => + sp.GetRequiredService(), + SerializationFormat.Json or _ => sp.GetRequiredService(), + } + ); + } + /// /// Adds a named ObsWebSocketClient, for applications driving more than one OBS instance. /// Resolve it with [FromKeyedServices(name)] or @@ -134,49 +120,50 @@ public static IObsWebSocketClientBuilder AddObsWebSocketClient( _ = optionsBuilder.Configure(configureOptions); } - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(TimeProvider.System); + AddSharedServices(services); - _ = services.AddKeyedSingleton( - name, - (sp, key) => - { - ObsWebSocketClientOptions options = sp.GetRequiredService< - IOptionsMonitor - >() - .Get((string)key!); + _ = services.AddKeyedSingleton(name, (sp, key) => Create(sp, (string)key!)); - IWebSocketMessageSerializer serializer = options.Format switch - { - SerializationFormat.MsgPack => - sp.GetRequiredService(), - SerializationFormat.Json or _ => sp.GetRequiredService(), - }; - - return new ObsWebSocketClient( - sp.GetRequiredService>(), - serializer, - Options.Create(options), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService() - ); - } + return new ObsWebSocketClientBuilder(services, name); + } + + /// + /// Builds a client for one registration, named or not. + /// + /// The provider to resolve from. + /// The client's key, or for the unnamed client. + private static ObsWebSocketClient Create(IServiceProvider services, string? name) + { + MonitorBackedOptions options = new( + services.GetRequiredService>(), + name ); - return new ObsWebSocketClientBuilder(services, name); + // Surfaces misconfiguration at resolve rather than on the first connect. Does not + // freeze anything; the client goes on reading the monitor per call. + _ = options.Value; + + return new ObsWebSocketClient( + services.GetRequiredService>(), + services.GetRequiredService(), + options, + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService() + ); } } /// -/// Presents the current monitored value as . +/// Presents one client's current monitored options as . /// /// The monitor to read from. -internal sealed class MonitorBackedOptions(IOptionsMonitor monitor) - : IOptions +/// The client's key, or for the unnamed client. +internal sealed class MonitorBackedOptions( + IOptionsMonitor monitor, + string? name +) : IOptions { /// - public ObsWebSocketClientOptions Value => monitor.CurrentValue; + public ObsWebSocketClientOptions Value => monitor.Get(name ?? Options.DefaultName); } diff --git a/ObsWebSocket.Core/ReconnectDelays.cs b/ObsWebSocket.Core/ReconnectDelays.cs index c770d58..ee641e1 100644 --- a/ObsWebSocket.Core/ReconnectDelays.cs +++ b/ObsWebSocket.Core/ReconnectDelays.cs @@ -32,6 +32,20 @@ public ReconnectDelays(ObsWebSocketClientOptions options) _fixedDelay = TimeSpan.FromMilliseconds(options.InitialReconnectDelayMs); } + /// Initializes delays for a connection's captured settings. + /// The settings the connection was established with. + public ReconnectDelays(Networking.ObsConnectionSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + _strategy = ObsWebSocketResilience.CreateRetryOptions( + settings.ReconnectBackoffMultiplier, + settings.InitialReconnectDelayMs, + settings.MaxReconnectDelayMs + ); + _fixedDelay = TimeSpan.FromMilliseconds(settings.InitialReconnectDelayMs); + } + /// Delays for a client with no configured backoff. public static ReconnectDelays Disabled { get; } = new(); diff --git a/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs b/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs index 4df817a..a857e8f 100644 --- a/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs +++ b/ObsWebSocket.Core/Serialization/IWebSocketMessageSerializer.cs @@ -1,4 +1,4 @@ -using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Protocol; namespace ObsWebSocket.Core.Serialization; @@ -39,6 +39,46 @@ Task SerializeAsync( CancellationToken cancellationToken = default ); + /// + /// Deserializes an incoming message that has already been assembled in memory. + /// + /// + /// + /// This is the shape the WebSocket transport actually produces: the receive loop reassembles + /// a message's fragments into one contiguous buffer before anything can be parsed, because + /// the envelope is not readable until the last fragment has arrived. Handing that buffer + /// straight to the serializer avoids wrapping it in a stream only for the serializer to copy + /// it back out again, which on the MessagePack path meant three full copies of every message + /// before a single byte was decoded. + /// + /// + /// The default implementation adapts to , + /// so an existing serializer outside this library keeps working; the two built in override it. + /// + /// + /// The complete message. + /// Cancellation token. + /// The deserialized message, or if it could not be read. + ValueTask DeserializeAsync( + ReadOnlyMemory message, + CancellationToken cancellationToken = default + ) + { + return Adapt(this, message, cancellationToken); + + static async ValueTask Adapt( + IWebSocketMessageSerializer serializer, + ReadOnlyMemory message, + CancellationToken cancellationToken + ) + { + using MemoryStream stream = new(message.ToArray(), writable: false); + return await serializer + .DeserializeAsync(stream, cancellationToken) + .ConfigureAwait(false); + } + } + /// /// Deserializes the raw payload data (e.g., JsonElement, object from MessagePack) into a /// specific target type, throwing when the payload cannot be read. diff --git a/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs b/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs index 1fd4a97..c574b63 100644 --- a/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs +++ b/ObsWebSocket.Core/Serialization/JsonMessageSerializer.cs @@ -53,6 +53,16 @@ public Task SerializeAsync( ) { ArgumentNullException.ThrowIfNull(messageStream); + + // Measuring and re-reading on failure both need seeking, so copy once if it cannot. + if (!messageStream.CanSeek) + { + using MemoryStream seekable = new(); + await messageStream.CopyToAsync(seekable, cancellationToken).ConfigureAwait(false); + return await DeserializeAsync(seekable.ToArray(), cancellationToken) + .ConfigureAwait(false); + } + if (messageStream.Length == 0) { _logger.LogAttemptedToDeserializeAnEmptyMessageStream(); @@ -105,6 +115,64 @@ public Task SerializeAsync( } } + /// + public ValueTask DeserializeAsync( + ReadOnlyMemory message, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (message.IsEmpty) + { + _logger.LogAttemptedToDeserializeAnEmptyMessageStream(); + return ValueTask.FromResult(null); + } + + try + { + // Utf8JsonReader over the buffer keeps the parse synchronous and copy free. + Utf8JsonReader reader = new(message.Span); + JsonTypeInfo> typeInfo = + (JsonTypeInfo>) + s_options.GetTypeInfo(typeof(IncomingMessage)); + IncomingMessage? parsed = JsonSerializer.Deserialize(ref reader, typeInfo); + + if (parsed is null) + { + _logger.LogJsonDeserializationResultedInNull(); + return ValueTask.FromResult(null); + } + + // The payload is read after the document it was parsed from is gone, so it has to + // own its data rather than point into that document. + IncomingMessage owned = new(parsed.Op, parsed.D.Clone()); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogDeserializedJsonMessageOp(owned.Op); + } + + return ValueTask.FromResult(owned); + } + catch (JsonException ex) + { + string rawJson = Encoding.UTF8.GetString( + message.Span[..Math.Min(message.Length, 1024)] + ); + _logger.LogJsonDeserializationFailedRawJson( + ex, + message.Length > 1024 ? rawJson + "..." : rawJson + ); + return ValueTask.FromResult(null); + } + catch (Exception ex) + { + _logger.LogFailedToDeserializeMessageFromStream(ex); + return ValueTask.FromResult(null); + } + } + /// public TPayload? DeserializePayload(object? rawPayloadData) where TPayload : class => DeserializePayloadCore(rawPayloadData); diff --git a/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs b/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs index 4c642e7..a0f8480 100644 --- a/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs +++ b/ObsWebSocket.Core/Serialization/MsgPackMessageSerializer.cs @@ -62,36 +62,47 @@ public Task SerializeAsync( throw new ArgumentException("Stream must be readable.", nameof(messageStream)); } - if (messageStream.Length == 0) + // No Length check: a Stream need not be seekable, and this is copied out in full anyway. + await using MemoryStream buffer = new(); + await messageStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + return await DeserializeAsync(buffer.ToArray(), cancellationToken).ConfigureAwait(false); + } + + /// + public ValueTask DeserializeAsync( + ReadOnlyMemory message, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (message.IsEmpty) { _logger.LogAttemptedToDeserializeAnEmptyMessageStream(); - return null; + return ValueTask.FromResult(null); } try { - await using MemoryStream buffer = new(); - await messageStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); - IncomingMessage> message = DeserializeIncomingEnvelope( - buffer.ToArray() - ); + // MessagePack reads from memory, so no stream round trip is needed. + IncomingMessage> envelope = DeserializeIncomingEnvelope(message); if (_logger.IsEnabled(LogLevel.Trace)) { - _logger.LogDeserializedMessagepackMessageOp(message.Op); + _logger.LogDeserializedMessagepackMessageOp(envelope.Op); } - return message; + return ValueTask.FromResult(envelope); } catch (MessagePackSerializationException ex) { _logger.LogMessagepackDeserializationFailed(ex); - return null; + return ValueTask.FromResult(null); } catch (Exception ex) { _logger.LogFailedToDeserializeMessageFromStream(ex); - return null; + return ValueTask.FromResult(null); } } diff --git a/ObsWebSocket.Core/Serialization/ObsSerializerFactory.cs b/ObsWebSocket.Core/Serialization/ObsSerializerFactory.cs new file mode 100644 index 0000000..054abf4 --- /dev/null +++ b/ObsWebSocket.Core/Serialization/ObsSerializerFactory.cs @@ -0,0 +1,12 @@ +namespace ObsWebSocket.Core.Serialization; + +/// +/// Supplies the serializer for a wire format, each time a connection is established. +/// +/// +/// can change at runtime, so the serializer is +/// resolved per connection rather than injected once. +/// +/// The format the connection will negotiate. +/// The serializer speaking that format. +public delegate IWebSocketMessageSerializer ObsSerializerFactory(SerializationFormat format); diff --git a/ObsWebSocket.Example/ObsSourceKinds.cs b/ObsWebSocket.Example/ObsSourceKinds.cs new file mode 100644 index 0000000..799730a --- /dev/null +++ b/ObsWebSocket.Example/ObsSourceKinds.cs @@ -0,0 +1,79 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Requests; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Example; + +/// +/// The input and filter kinds the connected OBS offers. +/// +/// +/// Kinds are platform and plugin dependent: audio capture is wasapi_output_capture on +/// Windows, pulse_output_capture on Linux and coreaudio_output_capture on macOS, and +/// a build without CEF has no browser source at all. Validation asks OBS what it has rather than +/// assuming, so a missing kind skips the checks that need it instead of failing the run. +/// +internal sealed record ObsSourceKinds( + string? AudioCapture, + string? Media, + string? Browser, + string? Color, + string? GainFilter, + string? ColorFilter +) +{ + /// Asks OBS which kinds it supports. + /// A connected client. + /// A token to cancel the lookup. + public static async Task ResolveAsync( + ObsWebSocketClient client, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(client); + + GetInputKindListResponseData inputs = await client + .Inputs.GetInputKindListAsync(new GetInputKindListRequestData(), cancellationToken) + .ConfigureAwait(false); + GetSourceFilterKindListResponseData filters = await client + .Filters.GetSourceFilterKindListAsync(cancellationToken) + .ConfigureAwait(false); + + HashSet inputKinds = new(inputs.InputKinds ?? [], StringComparer.Ordinal); + HashSet filterKinds = new(filters.SourceFilterKinds ?? [], StringComparer.Ordinal); + + return new ObsSourceKinds( + AudioCapture: First( + inputKinds, + "wasapi_output_capture", + "pulse_output_capture", + "coreaudio_output_capture" + ), + Media: First(inputKinds, "ffmpeg_source"), + Browser: First(inputKinds, "browser_source"), + Color: First(inputKinds, "color_source_v3", "color_source_v2", "color_source"), + GainFilter: First(filterKinds, "gain_filter"), + ColorFilter: First(filterKinds, "color_filter_v2", "color_filter") + ); + } + + /// Names the kinds that are absent, for reporting a skipped check. + public string Describe() => + string.Join( + ", ", + new (string Name, string? Kind)[] + { + ("audio capture", AudioCapture), + ("media", Media), + ("browser", Browser), + ("color", Color), + ("gain filter", GainFilter), + ("color filter", ColorFilter), + } + .Where(entry => entry.Kind is null) + .Select(entry => entry.Name) + ); + + private static string? First(HashSet available, params string[] preferred) => + Array.Find(preferred, available.Contains); +} diff --git a/ObsWebSocket.Example/ObsWebSocket.Example.csproj b/ObsWebSocket.Example/ObsWebSocket.Example.csproj index 29698c3..4f89a69 100644 --- a/ObsWebSocket.Example/ObsWebSocket.Example.csproj +++ b/ObsWebSocket.Example/ObsWebSocket.Example.csproj @@ -16,6 +16,9 @@ PreserveNewest + + PreserveNewest + diff --git a/ObsWebSocket.Example/Program.cs b/ObsWebSocket.Example/Program.cs index 27e78dd..c3d84ab 100644 --- a/ObsWebSocket.Example/Program.cs +++ b/ObsWebSocket.Example/Program.cs @@ -9,10 +9,18 @@ HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); AnsiConsole.Write(new Rule("[cyan]ObsWebSocket Example Tool[/]") { Justification = Justify.Left }); -// Reads appsettings.json, environment variables, command-line args -builder +// Reads appsettings.json, then environment variables, then command-line args. +// +// The last two are re-added deliberately. HostApplicationBuilder has already registered them, and +// adding the JSON file afterwards put it on top of both, so the file silently won over anything +// passed in. That made the tool unconfigurable from outside its own directory: pointing it at a +// different OBS with Obs__ServerUri appeared to work and connected to the configured endpoint +// anyway. Re-adding them restores the usual precedence, where what the caller supplies wins. +_ = builder .Configuration.SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables() + .AddCommandLine(args); builder.Logging.ClearProviders(); builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 19afeac..0acd54a 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -817,11 +817,17 @@ await _obsClient.MediaInputs.TriggerMediaActionAsync( } } + /// + /// Runs the live validation suite over both transports and reports a verdict, so the run can + /// be used as a gate rather than read. + /// private async Task RunTransportValidationSuiteAsync(CancellationToken cancellationToken) { int iterations = Math.Max(1, _validationOptions.ValidationIterations); Rule rule = new("[cyan]Transport Validation[/]") { Justification = Justify.Left }; AnsiConsole.Write(rule); + + List failures = []; for (int i = 0; i < iterations; i++) { _logger.LogInformation( @@ -830,24 +836,69 @@ private async Task RunTransportValidationSuiteAsync(CancellationToken cancellati iterations ); UiInfo($"Iteration {i + 1}/{iterations}: JSON then MsgPack"); - await RunTransportValidationCycleAsync(SerializationFormat.Json, cancellationToken) - .ConfigureAwait(false); - await RunTransportValidationCycleAsync(SerializationFormat.MsgPack, cancellationToken) - .ConfigureAwait(false); + + foreach ( + SerializationFormat format in (SerializationFormat[]) + [SerializationFormat.Json, SerializationFormat.MsgPack] + ) + { + try + { + failures.AddRange( + ( + await RunTransportValidationCycleAsync(format, cancellationToken) + .ConfigureAwait(false) + ).Select(failure => $"iteration {i + 1}, {format}: {failure}") + ); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A failed validation, not a crash; the other transport still runs. + _logger.LogError(ex, "The {Format} validation cycle threw.", format); + failures.Add( + $"iteration {i + 1}, {format}: threw {ex.GetType().Name}: {ex.Message}" + ); + } + } } + + if (failures.Count == 0) + { + UiSuccess($"Transport validation passed over {iterations} iteration(s)."); + return; + } + + UiError($"Transport validation failed with {failures.Count} check(s):"); + foreach (string failure in failures) + { + UiError($" {failure}"); + } + + _logger.LogError( + "Transport validation failed with {FailureCount} check(s).", + failures.Count + ); + + // The host shuts down gracefully, so the exit code carries the verdict. + Environment.ExitCode = 1; } - private async Task RunTransportValidationCycleAsync( + /// + /// Runs one transport's validation cycle. + /// + /// The wire format to validate. + /// A token to cancel the cycle. + /// The checks that failed, empty when the cycle passed. + private async Task> RunTransportValidationCycleAsync( SerializationFormat format, CancellationToken cancellationToken ) { ObsWebSocketClientOptions cycleOptions = CloneOptionsForFormat(format); - IWebSocketMessageSerializer serializer = CreateSerializer(format); await using ObsWebSocketClient cycleClient = new( _loggerFactory.CreateLogger(), - serializer, + CreateSerializer, Options.Create(cycleOptions), _connectionFactory ); @@ -855,6 +906,17 @@ CancellationToken cancellationToken await cycleClient.ConnectAsync(cancellationToken).ConfigureAwait(false); // Each transport is judged on its own run. SerializationFailureSink.Reset(); + + ObsSourceKinds kinds = await ObsSourceKinds + .ResolveAsync(cycleClient, cancellationToken) + .ConfigureAwait(false); + + // Enumerated once, before anything in the cycle changes OBS, and shared from there on. + // Asking again later reads an encoder the state change already freed, and OBS dies + // rather than answering (#25). + GetOutputListResponseData outputs = await cycleClient + .Outputs.GetOutputListAsync(cancellationToken) + .ConfigureAwait(false); try { GetVersionResponseData? version = await cycleClient @@ -1078,20 +1140,26 @@ customEvent is not null ); List<(string Label, bool Pass, string Detail)> settingsResults = - await ValidateSettingsModesAsync(cycleClient, inputs, cancellationToken) + await ValidateSettingsModesAsync(cycleClient, inputs, kinds, cancellationToken) .ConfigureAwait(false); List<(string Label, bool Pass, string Detail)> modernResults = - await ValidateModernApisAsync(cycleClient, healthChecks, cancellationToken) + await ValidateModernApisAsync( + cycleClient, + healthChecks, + kinds, + outputs, + cancellationToken + ) .ConfigureAwait(false); modernResults.AddRange( - await SweepEveryReadRequestAsync(cycleClient, cancellationToken) + await SweepEveryReadRequestAsync(cycleClient, kinds, outputs, cancellationToken) .ConfigureAwait(false) ); modernResults.AddRange( - await SweepEveryWriteRequestAsync(cycleClient, cancellationToken) + await SweepEveryWriteRequestAsync(cycleClient, kinds, outputs, cancellationToken) .ConfigureAwait(false) ); @@ -1148,6 +1216,14 @@ await SweepEveryWriteRequestAsync(cycleClient, cancellationToken) ); } AnsiConsole.Write(summary); + + return + [ + .. settingsResults + .Concat(modernResults) + .Where(result => !result.Pass) + .Select(result => $"{result.Label} ({result.Detail})"), + ]; } finally { @@ -1174,6 +1250,7 @@ private static async Task< > ValidateSettingsModesAsync( ObsWebSocketClient client, GetInputListResponseData? inputs, + ObsSourceKinds kinds, CancellationToken cancellationToken ) { @@ -1184,6 +1261,14 @@ CancellationToken cancellationToken return results; } + if (kinds.Browser is null || kinds.GainFilter is null) + { + results.Add( + ("Settings [all modes]", true, $"skipped: no {kinds.Describe()} kind on this OBS") + ); + return results; + } + // ── InputSettings ───────────────────────────────────────────────────── const string FixtureInputName = "__obsws_settings_browser"; const string FixtureFilter = "__obsws_settings_gain"; @@ -1200,7 +1285,7 @@ CancellationToken cancellationToken await client .Inputs.CreateInputAsync( - inputKind: "browser_source", + inputKind: kinds.Browser, inputName: FixtureInputName, settings: new BrowserSourceSettings( Url: "https://obsproject.com", @@ -1217,7 +1302,7 @@ await client await client .Filters.CreateSourceFilterAsync( new CreateSourceFilterRequestData( - filterKind: "gain_filter", + filterKind: kinds.GainFilter, filterName: FixtureFilter, sourceName: FixtureInputName ), @@ -1510,6 +1595,8 @@ private static async Task< > ValidateModernApisAsync( ObsWebSocketClient client, HealthCheckService healthChecks, + ObsSourceKinds kinds, + GetOutputListResponseData outputs, CancellationToken cancellationToken ) { @@ -3013,7 +3100,7 @@ await client .Inputs.CreateInputAsync( new( inputName: seeded, - inputKind: "wasapi_output_capture", + inputKind: kinds.AudioCapture!, sceneName: live.CurrentProgramSceneName ?? sceneName ), cancellationToken @@ -3375,10 +3462,6 @@ await TrySettingsCheckAsync( GetSceneTransitionListResponseData transitions = await client .Transitions.GetSceneTransitionListAsync(cancellationToken) .ConfigureAwait(false); - GetOutputListResponseData outputs = await client - .Outputs.GetOutputListAsync(cancellationToken) - .ConfigureAwait(false); - bool ok = monitors.Monitors.Count > 0 && transitions.Transitions.Count > 0 @@ -4534,7 +4617,7 @@ private static void RenderCommandHelp() _ = commandTable.AddRow( Markup.Escape("run-transport-tests"), Markup.Escape( - "Run validation cycle for the configured transport (version, scenes, inputs, filters, custom event, batch, settings modes 1/2/3)" + "Validate JSON and MsgPack against this OBS (version, scenes, inputs, filters, custom event, batch, settings modes 1/2/3). Prints a verdict and exits non-zero on failure." ) ); _ = commandTable.AddRow( @@ -4569,11 +4652,68 @@ private static void RenderCommandHelp() /// an input of the wrong kind) are reported as untested rather than as failures, so the count /// says how much of the surface was actually exercised. /// + /// + /// Waits for OBS to apply a profile change, which it does after answering the request. + /// + private static async Task WaitForProfileAsync( + ObsWebSocketClient client, + Func applied, + CancellationToken cancellationToken + ) + { + for (int attempt = 0; attempt < 50; attempt++) + { + GetProfileListResponseData profiles = await client + .Config.GetProfileListAsync(cancellationToken) + .ConfigureAwait(false); + if (applied(profiles)) + { + return true; + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + return false; + } + private const string FixtureFilterName = "__obsws_rsweep_filter"; + /// + /// Settings for the fixtures' media source, pointing at the clip shipped next to the binary. + /// + /// + /// A media source with no file is never playing, and OBS answers the cursor requests with + /// 604, so they are sent but never exercised. + /// + private static JsonElement MediaFixtureSettings() + { + ArrayBufferWriter buffer = new(); + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WriteString( + "local_file", + Path.Combine(AppContext.BaseDirectory, "fixtures", "media-fixture.mp4") + ); + writer.WriteBoolean("is_local_file", true); + writer.WriteBoolean("looping", true); + writer.WriteEndObject(); + writer.Flush(); + } + + using JsonDocument document = JsonDocument.Parse(buffer.WrittenMemory); + return document.RootElement.Clone(); + } + private static async Task< List<(string Label, bool Pass, string Detail)> - > SweepEveryReadRequestAsync(ObsWebSocketClient client, CancellationToken cancellationToken) + > SweepEveryReadRequestAsync( + ObsWebSocketClient client, + ObsSourceKinds kinds, + GetOutputListResponseData outputs, + CancellationToken cancellationToken + ) { List unreadable = []; List untested = []; @@ -4609,12 +4749,6 @@ async Task Probe(string name, Func call) .ConfigureAwait(false); string inputName = inputs.Inputs[0].InputName; - GetSceneItemListResponseData items = await client - .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) - .ConfigureAwait(false); - long sceneItemId = items.SceneItems.Count > 0 ? items.SceneItems[0].SceneItemId : -1; - string? itemSourceName = items.SceneItems.Count > 0 ? items.SceneItems[0].SourceName : null; - GetInputKindListResponseData inputKinds = await client .Inputs.GetInputKindListAsync(new(), cancellationToken) .ConfigureAwait(false); @@ -4625,9 +4759,6 @@ async Task Probe(string name, Func call) .ConfigureAwait(false); string filterKind = filterKinds.SourceFilterKinds[0]; - GetOutputListResponseData outputs = await client - .Outputs.GetOutputListAsync(cancellationToken) - .ConfigureAwait(false); string outputName = outputs.Outputs[0].OutputName; GetGroupListResponseData groups = await client @@ -4665,17 +4796,18 @@ await client .ConfigureAwait(false); await client .Inputs.CreateInputAsync( - new( - inputName: audioInput, - inputKind: "wasapi_output_capture", - sceneName: readScene - ), + new(inputName: audioInput, inputKind: kinds.AudioCapture!, sceneName: readScene), cancellationToken ) .ConfigureAwait(false); await client .Inputs.CreateInputAsync( - new(inputName: mediaInput, inputKind: "ffmpeg_source", sceneName: readScene), + new( + inputName: mediaInput, + inputKind: "ffmpeg_source", + inputSettings: MediaFixtureSettings(), + sceneName: readScene + ), cancellationToken ) .ConfigureAwait(false); @@ -4696,6 +4828,14 @@ await client .ConfigureAwait(false); } + // From the fixture, not from the program scene: a fresh OBS profile opens on an empty + // scene, and the seven scene item requests then go unexercised. + GetSceneItemListResponseData fixtureItems = await client + .SceneItems.GetSceneItemListAsync(new(sceneName: readScene), cancellationToken) + .ConfigureAwait(false); + long sceneItemId = fixtureItems.SceneItems[0].SceneItemId; + string itemSourceName = fixtureItems.SceneItems[0].SourceName; + try { await Probe( @@ -4966,76 +5106,69 @@ await Probe( untested.Add("GetGroupSceneItemList (no group; the protocol cannot create one)"); } - if (sceneItemId >= 0) - { - await Probe( - "GetSceneItemId", - () => - client.SceneItems.GetSceneItemIdAsync( - new(sourceName: itemSourceName!, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemSource", - () => - client.SceneItems.GetSceneItemSourceAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemTransform", - () => - client.SceneItems.GetSceneItemTransformAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemEnabled", - () => - client.SceneItems.GetSceneItemEnabledAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemLocked", - () => - client.SceneItems.GetSceneItemLockedAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemIndex", - () => - client.SceneItems.GetSceneItemIndexAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemBlendMode", - () => - client.SceneItems.GetSceneItemBlendModeAsync( - new(sceneItemId: sceneItemId, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - } - else - { - untested.Add("7 scene item requests (the program scene has no items)"); - } + await Probe( + "GetSceneItemId", + () => + client.SceneItems.GetSceneItemIdAsync( + new(sourceName: itemSourceName, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemSource", + () => + client.SceneItems.GetSceneItemSourceAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemTransform", + () => + client.SceneItems.GetSceneItemTransformAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemEnabled", + () => + client.SceneItems.GetSceneItemEnabledAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemLocked", + () => + client.SceneItems.GetSceneItemLockedAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemIndex", + () => + client.SceneItems.GetSceneItemIndexAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemBlendMode", + () => + client.SceneItems.GetSceneItemBlendModeAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); await Probe( "GetCurrentProgramScene", @@ -5170,7 +5303,12 @@ await client /// private static async Task< List<(string Label, bool Pass, string Detail)> - > SweepEveryWriteRequestAsync(ObsWebSocketClient client, CancellationToken cancellationToken) + > SweepEveryWriteRequestAsync( + ObsWebSocketClient client, + ObsSourceKinds kinds, + GetOutputListResponseData outputs, + CancellationToken cancellationToken + ) { List unsendable = []; List declined = []; @@ -5206,6 +5344,8 @@ async Task Probe(string name, Func call) .ConfigureAwait(false); string originalProgramScene = scenesBefore.CurrentProgramSceneName!; + string outputName = outputs.Outputs[0].OutputName; + // ── Fixture ────────────────────────────────────────────────────────── await Probe( "CreateScene", @@ -5236,7 +5376,7 @@ await Probe( client.Inputs.CreateInputAsync( new( inputName: audioInput, - inputKind: "wasapi_output_capture", + inputKind: kinds.AudioCapture!, sceneName: sceneName ), cancellationToken @@ -5250,6 +5390,7 @@ await Probe( new( inputName: mediaInput, inputKind: "ffmpeg_source", + inputSettings: MediaFixtureSettings(), sceneName: sceneName ), cancellationToken @@ -5535,7 +5676,36 @@ await Probe( .ConfigureAwait(false); inputName = renamedInput; - // ── Media inputs, on an input that is not one ──────────────────── + // ── Media inputs ───────────────────────────────────────────────── + // The cursor requests only reach their own answer while the clip is playing. + await Probe( + "TriggerMediaInputAction (play)", + () => + client.MediaInputs.TriggerMediaInputActionAsync( + new(mediaAction: MediaInputAction.Play, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // Playback starts a moment after OBS accepts the action, and the cursor requests are + // answered only once it has. + for (int attempt = 0; attempt < 20; attempt++) + { + GetMediaInputStatusResponseData status = await client + .MediaInputs.GetMediaInputStatusAsync( + new(inputName: mediaInput), + cancellationToken + ) + .ConfigureAwait(false); + if (status.MediaState is "OBS_MEDIA_STATE_PLAYING" or "OBS_MEDIA_STATE_PAUSED") + { + break; + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + await Probe( "SetMediaInputCursor", () => @@ -5683,6 +5853,81 @@ await Probe( ) .ConfigureAwait(false); + // CreateProfile activates the profile it creates, and removing the active one leaves + // OBS writing into a directory it has deleted (#42). Switch back first, and wait for + // each step: OBS answers these before it applies them. + string sweepProfile = $"__obsws_wsweep_profile_{suffix}"; + GetProfileListResponseData profilesBefore = await client + .Config.GetProfileListAsync(cancellationToken) + .ConfigureAwait(false); + string originalProfile = profilesBefore.CurrentProfileName!; + + await Probe( + "CreateProfile", + () => + client.Config.CreateProfileAsync( + new(profileName: sweepProfile), + cancellationToken + ) + ) + .ConfigureAwait(false); + + if ( + await WaitForProfileAsync( + client, + list => list.CurrentProfileName == sweepProfile, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + await Probe( + "SetCurrentProfile", + () => + client.Config.SetCurrentProfileAsync( + new(profileName: originalProfile), + cancellationToken + ) + ) + .ConfigureAwait(false); + + if ( + await WaitForProfileAsync( + client, + list => list.CurrentProfileName == originalProfile, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + await Probe( + "RemoveProfile", + () => + client.Config.RemoveProfileAsync( + new(profileName: sweepProfile), + cancellationToken + ) + ) + .ConfigureAwait(false); + _ = await WaitForProfileAsync( + client, + list => !list.Profiles.Contains(sweepProfile), + cancellationToken + ) + .ConfigureAwait(false); + } + else + { + declined.Add("RemoveProfile (not sent: OBS stayed on the sweep's profile)"); + } + } + else + { + declined.Add( + "SetCurrentProfile, RemoveProfile (not sent: the new profile never became active)" + ); + } + GetProfileParameterResponseData profileParameter = await client .Config.GetProfileParameterAsync( new(parameterCategory: "Output", parameterName: "Mode"), @@ -5741,14 +5986,24 @@ await Probe( GetStreamServiceSettingsResponseData streamService = await client .Config.GetStreamServiceSettingsAsync(cancellationToken) .ConfigureAwait(false); + // Written back unchanged where OBS has settings. A fresh install has none and OBS + // rejects an empty object, so the request would never be exercised; a placeholder for + // the service it already reports keeps the check real and overwrites nothing. + JsonElement serviceSettings = + streamService.StreamServiceSettings is JsonElement existing + && existing.ValueKind == JsonValueKind.Object + && existing.EnumerateObject().Any() + ? existing + : JsonDocument + .Parse("""{"server":"auto","service":"Twitch"}""") + .RootElement.Clone(); await Probe( "SetStreamServiceSettings", () => client.Config.SetStreamServiceSettingsAsync( new( streamServiceType: streamService.StreamServiceType, - streamServiceSettings: streamService.StreamServiceSettings - ?? JsonDocument.Parse("{}").RootElement.Clone() + streamServiceSettings: serviceSettings ), cancellationToken ) @@ -5777,18 +6032,29 @@ await Probe( ) ) .ConfigureAwait(false); - await Probe( - "SetCurrentSceneTransitionSettings", - () => - client.Transitions.SetCurrentSceneTransitionSettingsAsync( - new( - transitionSettings: transition.TransitionSettings - ?? JsonDocument.Parse("{}").RootElement.Clone() - ), - cancellationToken - ) - ) - .ConfigureAwait(false); + // Fade and Cut have nothing to configure and OBS answers 606. Select a configurable + // transition in OBS to exercise this one; the CI scene collection ships with one. + if (transition.TransitionConfigurable) + { + await Probe( + "SetCurrentSceneTransitionSettings", + () => + client.Transitions.SetCurrentSceneTransitionSettingsAsync( + new( + transitionSettings: transition.TransitionSettings + ?? JsonDocument.Parse("{}").RootElement.Clone() + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + else + { + declined.Add( + $"SetCurrentSceneTransitionSettings (not sent: '{transition.TransitionName}' has nothing to configure)" + ); + } // ── UI and studio mode, restored below ─────────────────────────── GetStudioModeEnabledResponseData studio = await client @@ -5878,10 +6144,6 @@ await Probe( ) .ConfigureAwait(false); - GetOutputListResponseData outputs = await client - .Outputs.GetOutputListAsync(cancellationToken) - .ConfigureAwait(false); - string outputName = outputs.Outputs[0].OutputName; await Probe( "StopOutput", () => diff --git a/ObsWebSocket.Example/appsettings.json b/ObsWebSocket.Example/appsettings.json index 3872933..e144d3d 100644 --- a/ObsWebSocket.Example/appsettings.json +++ b/ObsWebSocket.Example/appsettings.json @@ -16,7 +16,13 @@ "Password": "EMmSgMckicN6lSL3", // OPTIONAL: Specify event subscriptions (defaults to 'All' non-high-volume if omitted) // See ObsWebSocket.Core.Protocol.Generated.EventSubscription for flags - "EventSubscriptions": null // Example: (1 << 0) | (1 << 2) for General and Scenes + "EventSubscriptions": null, // Example: (1 << 0) | (1 << 2) for General and Scenes + // OPTIONAL: Wire format, "Json" (default) or "MsgPack". Changing this at runtime reconnects + // and negotiates the new sub-protocol; the serializer is chosen per connection. + "Format": "Json", + // OPTIONAL: Ceiling on a single inbound message, in bytes. Defaults to 64 MiB. Size it to the + // largest response you ask OBS for: a 4K GetSourceScreenshot is several megabytes of base64. + "MaxIncomingMessageBytes": 67108864 }, "ExampleValidation": { // When true, runs JSON + MsgPack validation before entering the interactive command loop. diff --git a/ObsWebSocket.Example/fixtures/media-fixture.mp4 b/ObsWebSocket.Example/fixtures/media-fixture.mp4 new file mode 100644 index 0000000..465a982 Binary files /dev/null and b/ObsWebSocket.Example/fixtures/media-fixture.mp4 differ diff --git a/ObsWebSocket.Tests/ClientContractTests.cs b/ObsWebSocket.Tests/ClientContractTests.cs new file mode 100644 index 0000000..d989f1d --- /dev/null +++ b/ObsWebSocket.Tests/ClientContractTests.cs @@ -0,0 +1,691 @@ +using System.Buffers; +using System.Diagnostics.Metrics; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Events; +using ObsWebSocket.Core.Events.Generated; +using ObsWebSocket.Core.Networking; +using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Protocol.Common; +using ObsWebSocket.Core.Protocol.Events; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// Invariants the client has to keep: what it accepts off the wire, what it serializes +/// re-identification against, what it treats as per-connection state, and what it reports. +/// +[TestClass] +public sealed class ClientContractTests +{ + private const int TimeoutMs = 20_000; + + #region Inbound message size limit + + /// + /// An endless fragmented message stops at the ceiling instead of allocating without bound. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task ReceiveLoop_MessageExceedingTheLimit_FailsInsteadOfGrowing() + { + const int limit = 32 * 1024; + long bytesProduced = 0; + + (ObsWebSocketClient client, Mock socket, _, _) = + TestUtils.BuildMockedClientInfrastructure(o => o.MaxIncomingMessageBytes = limit); + + socket.Reset(); + _ = socket.SetupGet(c => c.State).Returns(WebSocketState.Open); + _ = socket.Setup(c => c.Abort()); + _ = socket.Setup(c => c.Dispose()); + + // Never sets EndOfMessage. + _ = socket + .Setup(c => c.ReceiveAsync(It.IsAny>(), It.IsAny())) + .Returns( + (Memory buffer, CancellationToken ct) => + { + buffer.Span.Fill(0x41); + _ = Interlocked.Add(ref bytesProduced, buffer.Length); + return new ValueTask( + new ValueWebSocketReceiveResult( + buffer.Length, + WebSocketMessageType.Text, + endOfMessage: false + ) + ); + } + ); + + using CancellationTokenSource lifetime = new(); + ObsConnectionContext connection = TestUtils.CreateConnectionContext( + socket.Object, + Mock.Of(), + lifetime.Token + ); + TestUtils.SetPrivateField(client, "_clientLifetimeCts", lifetime); + TestUtils.SetPrivateField(client, "_connection", connection); + + ObsWebSocketMessageTooLargeException thrown = + await Assert.ThrowsExactlyAsync(() => + RunReceiveLoopAsync(client, connection) + ); + + Assert.AreEqual(limit, thrown.MaxBytes); + Assert.IsGreaterThan(limit, thrown.AttemptedBytes, "The limit should have been crossed."); + + // Stops within one receive buffer of the ceiling, not some multiple of it. + Assert.IsLessThanOrEqualTo( + limit + ObsWebSocketClient.ReceiveBufferSize, + Interlocked.Read(ref bytesProduced), + "The loop read well past the limit before stopping." + ); + } + + /// + /// A message that fits is still delivered, so the limit is a ceiling and not a throttle. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task ReceiveLoop_MessageWithinTheLimit_IsDelivered() + { + byte[] payload = Encoding.UTF8.GetBytes( + JsonSerializer.Serialize( + new IncomingMessage( + WebSocketOpCode.Hello, + JsonDocument.Parse("""{"rpcVersion":1}""").RootElement.Clone() + ), + TestUtils.s_jsonSerializerOptions + ) + ); + + (ObsWebSocketClient client, Mock socket, _, _) = + TestUtils.BuildMockedClientInfrastructure(o => + o.MaxIncomingMessageBytes = ObsWebSocketClient.ReceiveBufferSize + ); + + socket.Reset(); + _ = socket.SetupGet(c => c.State).Returns(WebSocketState.Open); + _ = socket.Setup(c => c.Abort()); + _ = socket.Setup(c => c.Dispose()); + _ = socket.SetupGet(c => c.CloseStatus).Returns((WebSocketCloseStatus?)null); + _ = socket.SetupGet(c => c.CloseStatusDescription).Returns((string?)null); + SetupFragmentedThenClose(socket, payload, fragmentSize: 8); + + using CancellationTokenSource lifetime = new(); + ObsConnectionContext connection = TestUtils.CreateConnectionContext( + socket.Object, + new JsonMessageSerializer(NullLogger.Instance), + lifetime.Token + ); + TestUtils.SetPrivateField(client, "_clientLifetimeCts", lifetime); + TestUtils.SetPrivateField(client, "_connection", connection); + + await RunReceiveLoopAsync(client, connection); + + Assert.IsTrue( + connection.Hello.Task.IsCompletedSuccessfully, + "A message assembled from many small fragments should still have been dispatched." + ); + } + + #endregion + + #region Re-identification single flight + + /// + /// Two concurrent re-identifications do not overlap on one connection. + /// + /// + /// Identified carries no request id, so overlapping operations cannot be matched to + /// their replies. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task ReidentifyAsync_TwoConcurrentCallers_AreSerialized() + { + ( + ObsWebSocketClient client, + Mock serializer, + Mock socket + ) = TestUtils.SetupConnectedClientForceState(); + + int inFlight = 0; + int maxObservedInFlight = 0; + TaskCompletionSource firstSendObserved = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + _ = serializer + .Setup(s => + s.SerializeAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .Returns( + async (OutgoingMessage _, CancellationToken _) => + { + int now = Interlocked.Increment(ref inFlight); + _ = InterlockedMax(ref maxObservedInFlight, now); + _ = firstSendObserved.TrySetResult(); + + // Held open so a missing gate would put both callers here at once. + await Task.Delay(150); + _ = Interlocked.Decrement(ref inFlight); + return []; + } + ); + + ObsConnectionContext connection = TestUtils.GetPrivateField( + client, + "_connection" + )!; + + // Answers whichever waiter is installed, as the receive loop does. + using CancellationTokenSource replies = new(); + Task replyPump = Task.Run( + async () => + { + while (!replies.IsCancellationRequested) + { + _ = connection.Identified.TrySetResult(IdentifiedEnvelope()); + await Task.Delay(10, CancellationToken.None); + } + }, + CancellationToken.None + ); + + _ = serializer + .Setup(s => s.DeserializePayload(It.IsAny())) + .Returns(new IdentifiedPayload(1)); + + // Released together; sequential calls could not overlap. + Task first = client.ReidentifyAsync((uint)EventSubscription.All); + Task second = client.ReidentifyAsync((uint)EventSubscription.General); + await firstSendObserved.Task; + await Task.WhenAll(first, second); + + await replies.CancelAsync(); + await replyPump.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + + Assert.AreEqual( + 1, + Volatile.Read(ref maxObservedInFlight), + "Two re-identifications were in flight at once on one connection." + ); + } + + #endregion + + #region Configuration is not mutated + + /// + /// Connecting does not write back onto the options object it was handed, which under + /// IOptionsMonitor is shared with every other reader. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task ConnectAsync_WithAnOutOfRangeMultiplier_LeavesTheOptionsAlone() + { + ObsWebSocketClientOptions options = new() + { + ServerUri = new Uri("ws://testhost:4455"), + ReconnectBackoffMultiplier = 0.25, + AutoReconnectEnabled = false, + MaxReconnectAttempts = 0, + HandshakeTimeoutMs = 50, + }; + + Mock socket = new(MockBehavior.Loose); + _ = socket.SetupGet(c => c.State).Returns(WebSocketState.Closed); + _ = socket.SetupGet(c => c.Options).Returns(new ClientWebSocket().Options); + _ = socket + .Setup(c => c.ConnectAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new WebSocketException("refused")); + + Mock factory = new(MockBehavior.Strict); + _ = factory.Setup(f => f.CreateConnection()).Returns(socket.Object); + + await using ObsWebSocketClient client = new( + NullLogger.Instance, + _ => + Mock.Of(s => + s.ProtocolSubProtocol == "obswebsocket.json" + ), + Options.Create(options), + factory.Object + ); + + _ = await Assert.ThrowsAsync(() => client.ConnectAsync()); + + Assert.AreEqual( + 0.25, + options.ReconnectBackoffMultiplier, + "Connecting rewrote the caller's configuration." + ); + } + + #endregion + + #region Named client registration + + /// + /// A container with only named clients resolves, and each reads its own options. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task AddNamedClient_Only_ResolvesAndReadsItsOwnLiveOptions() + { + ServiceCollection services = new(); + _ = services.AddLogging(); + _ = services.AddObsWebSocketClient( + "left", + o => + { + o.ServerUri = new Uri("ws://left:4455"); + o.RequestTimeoutMs = 1111; + } + ); + _ = services.AddObsWebSocketClient( + "right", + o => + { + o.ServerUri = new Uri("ws://right:4455"); + o.RequestTimeoutMs = 2222; + } + ); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + ObsWebSocketClient left = provider.GetRequiredKeyedService("left"); + ObsWebSocketClient right = provider.GetRequiredKeyedService("right"); + + Assert.AreNotSame(left, right); + Assert.AreEqual(1111, left._options.Value.RequestTimeoutMs); + Assert.AreEqual(2222, right._options.Value.RequestTimeoutMs); + + // A snapshot would survive the cache reset; the monitor re-reads. + provider + .GetRequiredService>() + .TryRemove("left"); + _ = provider.GetRequiredService>().GetType(); + + Assert.AreEqual( + 1111, + left._options.Value.RequestTimeoutMs, + "The named client should still resolve its own options after a cache reset." + ); + } + + /// + /// A named client must pick the serializer its own format asks for. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task AddNamedClient_WithDifferentFormats_EachGetsItsOwnSerializer() + { + ServiceCollection services = new(); + _ = services.AddLogging(); + _ = services.AddObsWebSocketClient( + "json", + o => + { + o.ServerUri = new Uri("ws://json:4455"); + o.Format = SerializationFormat.Json; + } + ); + _ = services.AddObsWebSocketClient( + "pack", + o => + { + o.ServerUri = new Uri("ws://pack:4455"); + o.Format = SerializationFormat.MsgPack; + } + ); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + ObsSerializerFactory factory = provider.GetRequiredService(); + _ = Assert.IsInstanceOfType(factory(SerializationFormat.Json)); + _ = Assert.IsInstanceOfType(factory(SerializationFormat.MsgPack)); + } + + #endregion + + #region Transport switching + + /// + /// The serializer follows the configured format at connection time, not at registration time. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task ChangingFormat_ChangesTheSerializerTheNextConnectionUses() + { + List requested = []; + + ObsWebSocketClientOptions options = new() + { + ServerUri = new Uri("ws://testhost:4455"), + Format = SerializationFormat.Json, + AutoReconnectEnabled = false, + MaxReconnectAttempts = 0, + HandshakeTimeoutMs = 50, + }; + + Mock factory = new(MockBehavior.Strict); + _ = factory + .Setup(f => f.CreateConnection()) + .Returns(() => + { + Mock socket = new(MockBehavior.Loose); + _ = socket.SetupGet(c => c.State).Returns(WebSocketState.Closed); + _ = socket.SetupGet(c => c.Options).Returns(new ClientWebSocket().Options); + _ = socket + .Setup(c => c.ConnectAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new WebSocketException("refused")); + return socket.Object; + }); + + await using ObsWebSocketClient client = new( + NullLogger.Instance, + format => + { + requested.Add(format); + return Mock.Of(s => + s.ProtocolSubProtocol + == ( + format == SerializationFormat.MsgPack + ? "obswebsocket.msgpack" + : "obswebsocket.json" + ) + ); + }, + Options.Create(options), + factory.Object + ); + + _ = await Assert.ThrowsAsync(() => client.ConnectAsync()); + options.Format = SerializationFormat.MsgPack; + _ = await Assert.ThrowsAsync(() => client.ConnectAsync()); + + CollectionAssert.AreEqual( + new[] { SerializationFormat.Json, SerializationFormat.MsgPack }, + requested, + "The second connection should have been built for the newly configured format." + ); + } + + #endregion + + #region Event drop telemetry + + /// + /// A stream that overflows reports how many events it discarded. + /// + /// + /// Dropping is intended; TryWrite reporting success on eviction is what makes it + /// invisible without a counter. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task EventStream_WhenTheConsumerFallsBehind_CountsTheDrops() + { + using MeterFactoryStub meterFactory = new(); + using ObsWebSocketMetrics metrics = new(meterFactory); + + long dropped = 0; + using MeterListener listener = new(); + listener.InstrumentPublished = (instrument, l) => + { + if (instrument.Name == "obsws.events.dropped") + { + l.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback( + (_, measurement, _, _) => Interlocked.Add(ref dropped, measurement) + ); + listener.Start(); + + EventHandler? handler = null; + IAsyncEnumerable stream = + EventStream.Create( + h => handler = h, + h => handler -= h, + capacity: 1, + metrics, + CancellationToken.None + ); + + await using IAsyncEnumerator enumerator = + stream.GetAsyncEnumerator(); + ValueTask pending = enumerator.MoveNextAsync(); + + const int raised = 10; + for (int i = 0; i < raised; i++) + { + handler!.Invoke( + null, + new CurrentProgramSceneChangedEventArgs( + new CurrentProgramSceneChangedPayload($"scene-{i}", Guid.NewGuid().ToString()) + ) + ); + } + + _ = await pending; + listener.RecordObservableInstruments(); + + Assert.IsGreaterThan( + 0, + Interlocked.Read(ref dropped), + "A capacity-one stream fed ten events without being read should report drops." + ); + } + + #endregion + + #region Serializer contract + + /// + /// Both serializers read a stream that cannot seek or report a length. + /// + [TestMethod] + [Timeout(TimeoutMs)] + public async Task Serializers_GivenANonSeekableStream_StillRead() + { + byte[] json = Encoding.UTF8.GetBytes("""{"op":0,"d":{"rpcVersion":1}}"""); + + object? fromJson = await new JsonMessageSerializer( + NullLogger.Instance + ).DeserializeAsync(new ForwardOnlyStream(json)); + + Assert.IsInstanceOfType>(fromJson); + + byte[] pack = await new MsgPackMessageSerializer( + NullLogger.Instance + ).SerializeAsync( + new OutgoingMessage( + WebSocketOpCode.Reidentify, + new ReidentifyPayload(0) + ) + ); + + object? fromPack = await new MsgPackMessageSerializer( + NullLogger.Instance + ).DeserializeAsync(new ForwardOnlyStream(pack)); + + Assert.IsInstanceOfType>>(fromPack); + } + + /// The memory and stream overloads produce the same envelope. + [TestMethod] + [Timeout(TimeoutMs)] + public async Task Serializers_MemoryAndStreamOverloads_ProduceTheSameEnvelope() + { + byte[] json = Encoding.UTF8.GetBytes("""{"op":5,"d":{"eventType":"ExitStarted"}}"""); + JsonMessageSerializer serializer = new(NullLogger.Instance); + + IncomingMessage viaStream = + (IncomingMessage) + (await serializer.DeserializeAsync(new MemoryStream(json)))!; + IncomingMessage viaMemory = + (IncomingMessage) + (await serializer.DeserializeAsync(new ReadOnlyMemory(json)))!; + + Assert.AreEqual(viaStream.Op, viaMemory.Op); + Assert.AreEqual(viaStream.D.GetRawText(), viaMemory.D.GetRawText()); + } + + #endregion + + #region Helpers + + private static Task RunReceiveLoopAsync( + ObsWebSocketClient client, + ObsConnectionContext connection + ) + { + Func loop = TestUtils.GetPrivateMethodDelegate< + Func + >(client, "ReceiveLoopAsync")!; + return loop(connection); + } + + private static void SetupFragmentedThenClose( + Mock socket, + byte[] payload, + int fragmentSize + ) + { + int offset = 0; + _ = socket + .Setup(c => c.ReceiveAsync(It.IsAny>(), It.IsAny())) + .Returns( + (Memory buffer, CancellationToken _) => + { + if (offset >= payload.Length) + { + return new ValueTask( + new ValueWebSocketReceiveResult( + 0, + WebSocketMessageType.Close, + endOfMessage: true + ) + ); + } + + int count = Math.Min(fragmentSize, payload.Length - offset); + payload.AsSpan(offset, count).CopyTo(buffer.Span); + offset += count; + + return new ValueTask( + new ValueWebSocketReceiveResult( + count, + WebSocketMessageType.Text, + endOfMessage: offset >= payload.Length + ) + ); + } + ); + } + + private static object IdentifiedEnvelope() => + new IncomingMessage( + WebSocketOpCode.Identified, + JsonDocument.Parse("""{"negotiatedRpcVersion":1}""").RootElement.Clone() + ); + + private static int InterlockedMax(ref int target, int value) + { + int seen = Volatile.Read(ref target); + while (value > seen) + { + int previous = Interlocked.CompareExchange(ref target, value, seen); + if (previous == seen) + { + return value; + } + + seen = previous; + } + + return seen; + } + + /// A stream that cannot seek and refuses to report a length. + private sealed class ForwardOnlyStream(byte[] data) : Stream + { + private readonly MemoryStream _inner = new(data, writable: false); + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) => + _inner.Read(buffer, offset, count); + + public override int Read(Span buffer) => _inner.Read(buffer); + + public override void Flush() { } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + + /// A meter factory owning its meters, so a test can read instruments back. + private sealed class MeterFactoryStub : IMeterFactory + { + private readonly List _meters = []; + + public Meter Create(MeterOptions options) + { + Meter meter = new(options); + _meters.Add(meter); + return meter; + } + + public void Dispose() + { + foreach (Meter meter in _meters) + { + meter.Dispose(); + } + + _meters.Clear(); + } + } + + #endregion +} diff --git a/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj b/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj index af2c609..4738569 100644 --- a/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj +++ b/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj @@ -3,7 +3,7 @@ net10.0;net9.0;net11.0 Exe true - latest + 15.0 enable enable false @@ -13,9 +13,9 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ObsWebSocket.Tests/ObsWebSocketClientConnectionTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientConnectionTests.cs index 989c819..c93404a 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientConnectionTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientConnectionTests.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Net.WebSockets; using System.Text.Json; using Microsoft.Extensions.Time.Testing; @@ -172,23 +172,18 @@ public async Task ConnectAsync_SuccessfulFirstAttempt_RaisesCorrectEvents() // Mock Serializer _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) .Returns( - (Stream stream, CancellationToken ct) => + (ReadOnlyMemory message, CancellationToken ct) => { - byte[] receivedBytes; - using (MemoryStream ms = new()) - { - stream.CopyTo(ms); - receivedBytes = ms.ToArray(); - } - - stream.Position = 0; // Reset stream for potential re-read by logger etc. - return receivedBytes.SequenceEqual(helloBytes) - ? Task.FromResult(helloMsg) - : receivedBytes.SequenceEqual(identifiedBytes) - ? Task.FromResult(identifiedMsg) - : Task.FromResult(null); + byte[] receivedBytes = message.ToArray(); + return ValueTask.FromResult( + receivedBytes.SequenceEqual(helloBytes) ? helloMsg + : receivedBytes.SequenceEqual(identifiedBytes) ? identifiedMsg + : null + ); } ); SetupHandshakePayloadDeserialization(mockSerializer, helloPayload, identifiedPayload); @@ -315,7 +310,8 @@ ValueTask BlockReceiveAsync(CancellationToken ct) Times.AtLeast(2) // Expect Hello, Identified, then block ); mockSerializer.Verify( - s => s.DeserializeAsync(It.IsAny(), It.IsAny()), + s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()), Times.Exactly(2) // Hello, Identified ); @@ -463,21 +459,19 @@ public async Task ConnectAsync_AuthFailure_NoRetry_RaisesCorrectEvents() // Mock Serializer _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync( - (Stream stream, CancellationToken ct) => - { - if (receiveCallCount == 1) - { - stream.Position = 0; - return JsonSerializer.Deserialize>( - stream, - TestUtils.s_jsonSerializerOptions - ); - } - - return null; - } + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) + .Returns( + (ReadOnlyMemory message, CancellationToken ct) => + ValueTask.FromResult( + receiveCallCount == 1 + ? JsonSerializer.Deserialize>( + message.Span, + TestUtils.s_jsonSerializerOptions + ) + : null + ) ); SetupHandshakePayloadDeserialization( mockSerializer, @@ -531,7 +525,8 @@ await Task.WhenAny(disconnectedSignal.Task, Task.Delay(1000)) ); mockFactory.Verify(f => f.CreateConnection(), Times.Once()); mockSerializer.Verify( - s => s.DeserializeAsync(It.IsAny(), It.IsAny()), + s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()), Times.Once() // Only called for the Hello message ); mockSerializer.Verify( diff --git a/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs index ff04206..d14521b 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientEventTests.cs @@ -1,4 +1,4 @@ -using System.Buffers; +using System.Buffers; using System.Diagnostics; using System.Net.WebSockets; using System.Reflection; @@ -127,13 +127,20 @@ private static Task StartReceiveLoopAsync(ObsWebSocketClient client) Assert.IsNotNull(clientLifetimeCts, "Client lifetime CTS not found."); CancellationToken clientLifetimeToken = clientLifetimeCts.Token; - Func? receiveLoopDelegate = GetPrivateMethodDelegate< - Func + // The loop reads its socket, serializer and cancellation from the connection it was + // started for, so the test hands it the same connection the client is holding. + ObsConnectionContext? connection = TestUtils.GetPrivateField( + client, + "_connection" + ); + Assert.IsNotNull(connection, "Client connection not found."); + + Func? receiveLoopDelegate = GetPrivateMethodDelegate< + Func >(client, "ReceiveLoopAsync"); Assert.IsNotNull(receiveLoopDelegate, "ReceiveLoopAsync delegate not found."); - // Start the loop on a background thread, passing the client's lifetime token - return Task.Run(() => receiveLoopDelegate(clientLifetimeToken), clientLifetimeToken); + return Task.Run(() => receiveLoopDelegate(connection), clientLifetimeToken); } /// @@ -243,7 +250,9 @@ public async Task HandleEventMessage_SceneListChanged_RaisesCorrectEvent() // Mock Serializer _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) .ReturnsAsync(incomingMessage); EventPayloadBase? envelope = new EventPayloadBase( "SceneListChanged", @@ -287,7 +296,8 @@ await Task.WhenAny(eventReceivedSignal.Task, Task.Delay(TimeSpan.FromSeconds(3)) // Verify mocks mockSerializer.Verify( - s => s.DeserializeAsync(It.IsAny(), It.IsAny()), + s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()), Times.Once ); mockSerializer.Verify( @@ -349,7 +359,9 @@ public async Task HandleEventMessage_StudioModeStateChanged_RaisesCorrectEvent() // Mock Serializer _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) .ReturnsAsync(incomingMessage); EventPayloadBase? envelope = new EventPayloadBase( "StudioModeStateChanged", @@ -397,7 +409,8 @@ await Task.WhenAny(eventReceivedSignal.Task, Task.Delay(TimeSpan.FromSeconds(3)) Times.AtLeastOnce ); mockSerializer.Verify( - s => s.DeserializeAsync(It.IsAny(), It.IsAny()), + s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()), Times.Once ); mockSerializer.Verify( @@ -454,7 +467,9 @@ public async Task HandleEventMessage_ExitStarted_RaisesCorrectEvent() // Mock Serializer _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) .ReturnsAsync(incomingMessage); // Mock only the base deserialization, as EventData is null EventPayloadBase? envelope = new EventPayloadBase( @@ -487,7 +502,8 @@ await Task.WhenAny(eventReceivedSignal.Task, Task.Delay(TimeSpan.FromSeconds(3)) Times.AtLeastOnce ); mockSerializer.Verify( - s => s.DeserializeAsync(It.IsAny(), It.IsAny()), + s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()), Times.Once ); mockSerializer.Verify( @@ -521,8 +537,17 @@ public async Task HandleEventMessage_UnhandledEventType_DoesNotThrowAndLogsWarni TestUtils.SetPrivateField(client, "_logger", mockLogger.Object); TestUtils.SetPrivateField(client, "_connectionState", ConnectionState.Connected); TestUtils.SetPrivateProperty(client, "IsConnected", true); - TestUtils.SetPrivateField(client, "_webSocket", mockWebSocket.Object); - TestUtils.SetPrivateField(client, "_clientLifetimeCts", new CancellationTokenSource()); + CancellationTokenSource lifetime = new(); + TestUtils.SetPrivateField(client, "_clientLifetimeCts", lifetime); + TestUtils.SetPrivateField( + client, + "_connection", + TestUtils.CreateConnectionContext( + mockWebSocket.Object, + mockSerializer.Object, + lifetime.Token + ) + ); string unhandledEventType = "ThisEventDoesNotExist"; JsonElement innerEventData = TestUtils.ToJsonElement(new { some = "data" })!.Value; @@ -542,7 +567,9 @@ public async Task HandleEventMessage_UnhandledEventType_DoesNotThrowAndLogsWarni // --- Mock Serializer --- _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) .ReturnsAsync(incomingMessage); // Mock ONLY the base deserialization EventPayloadBase? envelope = new EventPayloadBase( @@ -587,7 +614,8 @@ public async Task HandleEventMessage_UnhandledEventType_DoesNotThrowAndLogsWarni Times.AtLeastOnce ); mockSerializer.Verify( - s => s.DeserializeAsync(It.IsAny(), It.IsAny()), + s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()), Times.Once ); mockSerializer.Verify( @@ -621,8 +649,17 @@ public async Task HandleEventMessage_PayloadDeserializationThrows_LogsError() TestUtils.SetPrivateField(client, "_logger", mockLogger.Object); TestUtils.SetPrivateField(client, "_connectionState", ConnectionState.Connected); TestUtils.SetPrivateProperty(client, "IsConnected", true); - TestUtils.SetPrivateField(client, "_webSocket", mockWebSocket.Object); - TestUtils.SetPrivateField(client, "_clientLifetimeCts", new CancellationTokenSource()); + CancellationTokenSource lifetime = new(); + TestUtils.SetPrivateField(client, "_clientLifetimeCts", lifetime); + TestUtils.SetPrivateField( + client, + "_connection", + TestUtils.CreateConnectionContext( + mockWebSocket.Object, + mockSerializer.Object, + lifetime.Token + ) + ); string eventType = "StudioModeStateChanged"; JsonElement innerEventDataJsonElement = TestUtils @@ -648,7 +685,9 @@ public async Task HandleEventMessage_PayloadDeserializationThrows_LogsError() // --- Mock Serializer --- _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) .ReturnsAsync(incomingMessage); // Base deserialization succeeds EventPayloadBase? envelope = new( @@ -703,7 +742,8 @@ out It.Ref.IsAny Times.AtLeastOnce ); mockSerializer.Verify( - s => s.DeserializeAsync(It.IsAny(), It.IsAny()), + s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()), Times.Once ); mockSerializer.Verify( diff --git a/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs index 166d12c..22686b8 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs @@ -114,15 +114,15 @@ private static ObsWebSocketClient CreateClient() ILogger logger = s_serviceProvider.GetRequiredService< ILogger >(); - IWebSocketMessageSerializer serializer = - s_serviceProvider.GetRequiredService(); + ObsSerializerFactory serializerFactory = + s_serviceProvider.GetRequiredService(); IOptions options = s_serviceProvider.GetRequiredService< IOptions >(); IWebSocketConnectionFactory connectionFactory = s_serviceProvider.GetRequiredService(); - return new ObsWebSocketClient(logger, serializer, options, connectionFactory); + return new ObsWebSocketClient(logger, serializerFactory, options, connectionFactory); } // --- Test Cases --- diff --git a/ObsWebSocket.Tests/ObsWebSocketDiTests.cs b/ObsWebSocket.Tests/ObsWebSocketDiTests.cs index 6eb804b..40213f0 100644 --- a/ObsWebSocket.Tests/ObsWebSocketDiTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketDiTests.cs @@ -96,8 +96,9 @@ public void AddObsWebSocketClient_DefaultFormat_ResolvesJsonSerializer() // Check the serializer injected into the client instance Assert.IsNotNull(resolvedClient); - IWebSocketMessageSerializer? injectedSerializer = - TestUtils.GetPrivateField(resolvedClient, "_serializer"); + IWebSocketMessageSerializer? injectedSerializer = TestUtils + .GetPrivateField(resolvedClient, "_serializerFactory") + ?.Invoke(SerializationFormat.Json); Assert.IsNotNull(injectedSerializer); _ = Assert.IsInstanceOfType(injectedSerializer); Assert.AreEqual("obswebsocket.json", injectedSerializer.ProtocolSubProtocol); @@ -127,8 +128,9 @@ public void AddObsWebSocketClient_JsonFormatConfigured_ResolvesJsonSerializer() Assert.IsNotNull(resolvedSerializer); _ = Assert.IsInstanceOfType(resolvedSerializer); Assert.IsNotNull(resolvedClient); - IWebSocketMessageSerializer? injectedSerializer = - TestUtils.GetPrivateField(resolvedClient, "_serializer"); + IWebSocketMessageSerializer? injectedSerializer = TestUtils + .GetPrivateField(resolvedClient, "_serializerFactory") + ?.Invoke(SerializationFormat.Json); Assert.IsNotNull(injectedSerializer); _ = Assert.IsInstanceOfType(injectedSerializer); Assert.AreEqual("obswebsocket.json", injectedSerializer.ProtocolSubProtocol); @@ -158,8 +160,9 @@ public void AddObsWebSocketClient_MsgPackFormatConfigured_ResolvesMsgPackSeriali Assert.IsNotNull(resolvedSerializer); _ = Assert.IsInstanceOfType(resolvedSerializer); Assert.IsNotNull(resolvedClient); - IWebSocketMessageSerializer? injectedSerializer = - TestUtils.GetPrivateField(resolvedClient, "_serializer"); + IWebSocketMessageSerializer? injectedSerializer = TestUtils + .GetPrivateField(resolvedClient, "_serializerFactory") + ?.Invoke(SerializationFormat.MsgPack); Assert.IsNotNull(injectedSerializer); _ = Assert.IsInstanceOfType(injectedSerializer); Assert.AreEqual("obswebsocket.msgpack", injectedSerializer.ProtocolSubProtocol); diff --git a/ObsWebSocket.Tests/ReadmeCompileCheck.cs b/ObsWebSocket.Tests/ReadmeCompileCheck.cs index 5a4967b..71e887d 100644 --- a/ObsWebSocket.Tests/ReadmeCompileCheck.cs +++ b/ObsWebSocket.Tests/ReadmeCompileCheck.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using ObsWebSocket.Core; using ObsWebSocket.Core.Events.Generated; using ObsWebSocket.Core.Protocol; @@ -7,6 +9,7 @@ using ObsWebSocket.Core.Protocol.Generated; using ObsWebSocket.Core.Protocol.Requests; using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Core.Serialization; namespace ObsWebSocket.Tests; @@ -587,4 +590,24 @@ internal static async Task StudioModeAsync(ObsWebSocketClient client, Cancellati } catch (ObsWebSocketTimeoutException) { } } + + internal static async Task WithoutDependencyInjectionAsync(ILoggerFactory loggerFactory) + { + await using ObsWebSocketClient client = new( + loggerFactory.CreateLogger(), + format => + format is SerializationFormat.MsgPack + ? new MsgPackMessageSerializer( + loggerFactory.CreateLogger() + ) + : new JsonMessageSerializer( + loggerFactory.CreateLogger() + ), + Options.Create( + new ObsWebSocketClientOptions { ServerUri = new Uri("ws://localhost:4455") } + ) + ); + + await client.ConnectAsync(); + } } diff --git a/ObsWebSocket.Tests/TestUtils.cs b/ObsWebSocket.Tests/TestUtils.cs index f0c18f3..1225cfe 100644 --- a/ObsWebSocket.Tests/TestUtils.cs +++ b/ObsWebSocket.Tests/TestUtils.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Net.WebSockets; using System.Reflection; using System.Text; @@ -116,7 +116,9 @@ Mock mockFactory // IWebSocketMessageSerializer (Defaults) _ = mockSerializer.SetupGet(s => s.ProtocolSubProtocol).Returns("obswebsocket.json"); _ = mockSerializer - .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny())) + .Setup(s => + s.DeserializeAsync(It.IsAny>(), It.IsAny()) + ) .ReturnsAsync((object?)null); _ = mockSerializer .Setup(s => @@ -160,6 +162,10 @@ Mock mockFactory // --- DI Registration --- _ = services.AddOptions(); _ = services.AddSingleton(mockSerializer.Object); + + // The client picks its serializer per connection now, so the mock is supplied through the + // factory. Returning it for every format is what keeps these tests format agnostic. + _ = services.AddSingleton(_ => _ => mockSerializer.Object); _ = services.AddSingleton(mockConnectionFactory.Object); _ = services.Configure(opts => { @@ -177,9 +183,11 @@ Mock mockFactory ServiceProvider provider = services.BuildServiceProvider(); ObsWebSocketClient client = provider.GetRequiredService(); - IWebSocketMessageSerializer? injectedSerializer = - GetPrivateField(client, "_serializer"); - return injectedSerializer != mockSerializer.Object + ObsSerializerFactory? injectedFactory = GetPrivateField( + client, + "_serializerFactory" + ); + return injectedFactory?.Invoke(SerializationFormat.Json) != mockSerializer.Object ? throw new InvalidOperationException( "DI failed to inject the mocked IWebSocketMessageSerializer." ) @@ -210,10 +218,15 @@ Mock mockConnection _ ) = BuildMockedClientInfrastructure(timeProvider: timeProvider); - SetPrivateField(client, "_webSocket", mockConnection.Object); + CancellationTokenSource lifetime = new(); + SetPrivateField(client, "_clientLifetimeCts", lifetime); + SetPrivateField( + client, + "_connection", + CreateConnectionContext(mockConnection.Object, mockSerializer.Object, lifetime.Token) + ); SetPrivateField(client, "_connectionState", ConnectionState.Connected); SetPrivateProperty(client, "IsConnected", true); - SetPrivateField(client, "_clientLifetimeCts", new CancellationTokenSource()); _ = mockConnection.SetupGet(c => c.State).Returns(WebSocketState.Open); @@ -247,11 +260,40 @@ internal static ObsWebSocket.Core.Protocol.Responses.GetVersionResponseData Samp PlatformDescription = "Windows 11", }; + /// + /// Builds a connection context, as the client does when a connection is established. + /// + /// The socket the connection runs on. + /// The serializer for the connection's format. + /// The client-wide token. + internal static ObsConnectionContext CreateConnectionContext( + IWebSocketConnection transport, + IWebSocketMessageSerializer serializer, + CancellationToken lifetime = default + ) => + new( + transport, + serializer, + ObsConnectionSettings.Capture( + new ObsWebSocketClientOptions { ServerUri = new Uri("ws://testhost:4455") } + ), + lifetime + ); + + /// + /// Dispatches a message as the receive loop would, on the client's current connection. + /// internal static void InvokeProcessIncomingMessage( ObsWebSocketClient client, object messageObject ) { + ObsConnectionContext connection = + GetPrivateField(client, "_connection") + ?? throw new InvalidOperationException( + "The client has no connection; use SetupConnectedClientForceState first." + ); + MethodInfo? method = typeof(ObsWebSocketClient).GetMethod( "ProcessIncomingMessage", @@ -259,7 +301,7 @@ object messageObject ) ?? throw new MissingMethodException("ObsWebSocketClient", "ProcessIncomingMessage"); try { - _ = method.Invoke(client, [messageObject]); + _ = method.Invoke(client, [connection, messageObject]); } catch (TargetInvocationException ex) { diff --git a/README.md b/README.md index 9e0efe2..e9468ce 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ integration. [![NuGet Version](https://img.shields.io/nuget/v/ObsWebSocket.Core.svg?style=flat-square&logo=nuget&logoColor=white)](https://www.nuget.org/packages/ObsWebSocket.Core/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -Targets `net11.0`, `net10.0` and `net9.0`. +Targets `net11.0`, `net10.0` and `net9.0`. The `net11.0` target is built against a .NET 11 preview +SDK until .NET 11 is released; `net10.0` and `net9.0` carry no preview dependency. ## Install @@ -15,8 +16,21 @@ Targets `net11.0`, `net10.0` and `net9.0`. dotnet add package ObsWebSocket.Core ``` -Requires OBS Studio 28 or newer with obs-websocket v5. Enable the server under -*Tools > WebSocket Server Settings*. +The package is pre-1.0 and currently published as a prerelease, so `--prerelease` is needed to +install it and the public surface can still change between versions. + +Enable the server under *Tools > WebSocket Server Settings*. + +Two different compatibility claims are worth separating: + +- **Base protocol**: OBS Studio 28 or newer, which is where obs-websocket v5 arrived. Connecting, + identifying, events and the long-standing requests work against any of those. +- **Full generated surface**: the request and event types are generated from a pinned upstream + protocol definition, recorded in [`protocol.lock.json`](protocol.lock.json). It includes requests + added well after v5 shipped, such as `GetCanvasList`. Calling one against an older OBS returns a + protocol error from the server rather than failing at compile time. + +Validation runs against the current OBS release; see [Example app](#example-app). ## Quick start @@ -532,8 +546,12 @@ The password can travel in the connection string or be set on the options; eithe OBS is often started after the application, and reconnect takes over. Options are read through `IOptionsMonitor`, so configuration changes take effect without a restart. -Timeouts and reconnect settings apply to the next call that uses them. Changing the endpoint, -password or transport reconnects. +This holds for a named client as much as for the unnamed one. + +Options divide in two. Timeouts and reconnect settings are read per call and apply to the next call +that uses them. The endpoint, password, wire format and event subscriptions are fixed for the life +of a connection, because the sub-protocol is agreed during the handshake and the serializer has to +match it; changing any of them reconnects, and the new connection is built for the new format. To configure in code instead: @@ -596,6 +614,41 @@ catch (ObsWebSocketRequestException ex) when (ex.StatusCode is RequestStatusCode `ObsWebSocketSerializationException` covers payloads that cannot be written or read. All three derive from `ObsWebSocketException`. +## Connection lifetime + +The socket, its serializer, the settings it was established with, its cancellation and its handshake +state are one unit, replaced together. That gives three guarantees: + +- The serializer is chosen when the connection is established, so changing `Format` never leaves a + reconnected socket speaking the old wire format. +- A reconnect replaces everything, and the previous receive loop finishes before the next starts. +- Disposal or a reconnect cancels the connection's token, so pending requests fail rather than wait + for a reply that cannot arrive. + +`IsConnected`, `NegotiatedRpcVersion` and `CurrentEventSubscriptions` describe the live connection; +the `Connecting`, `Connected`, `Disconnected`, `ConnectionFailed` and `AuthenticationFailure` events +mark the transitions. + +### Without dependency injection + +The constructor takes a factory rather than a serializer, since the format is a per-connection +decision: + +```csharp +await using ObsWebSocketClient client = new( + loggerFactory.CreateLogger(), + format => format is SerializationFormat.MsgPack + ? new MsgPackMessageSerializer(loggerFactory.CreateLogger()) + : new JsonMessageSerializer(loggerFactory.CreateLogger()), + Options.Create(new ObsWebSocketClientOptions { ServerUri = new Uri("ws://localhost:4455") })); + +await client.ConnectAsync(); +``` + +To pin one format for the client's lifetime, ignore the argument: `_ => serializer`. + +`AddObsWebSocketClient` does this for you, so nothing changes if you register through DI. + ## Reconnect Reconnect delays grow by `ReconnectBackoffMultiplier`, are capped at `MaxReconnectDelayMs`, and @@ -625,17 +678,43 @@ builder.Services.AddOpenTelemetry() .WithMetrics(m => m.AddMeter(ObsWebSocketDiagnostics.MeterName)); ``` -One activity per request, and one per batch rather than per item. Counters cover requests sent, -requests failed, events received and reconnect attempts, plus a request duration histogram. -Instruments are created from `IMeterFactory`. +One activity per request, and one per batch rather than per item. Instruments are created from +`IMeterFactory`: + +| Instrument | What it records | +| --- | --- | +| `obsws.requests.sent` | Requests sent, tagged by request type. | +| `obsws.requests.failed` | Requests OBS rejected, or that timed out. | +| `obsws.request.duration` | Time from sending a request to its response. | +| `obsws.events.received` | Events received, tagged by event type. | +| `obsws.reconnects` | Reconnection attempts. | +| `obsws.events.dropped` | Events discarded because an event stream's consumer fell behind. | +| `obsws.messages.dropped` | Inbound messages discarded without being dispatched. | + +The last two are worth wiring up if you rely on events: streams drop the oldest event when full, and +the receive loop ignores a message it cannot read. Both are deliberate and otherwise invisible. Timeouts and reconnect delays run on an injectable `TimeProvider`, so tests can drive them with `FakeTimeProvider`. +## Limits + +`MaxIncomingMessageBytes` caps how large a single inbound message may grow, and defaults to 64 MiB. +A WebSocket message arrives as any number of fragments and its size is only known once the last one +has been read, so without a ceiling the client assembles whatever it is sent. Crossing the limit +fails the connection with `ObsWebSocketMessageTooLargeException` rather than continuing to allocate. + +Size it to the largest response you actually ask OBS for, not to the receive buffer: a +`GetSourceScreenshot` of a 4K canvas is a base64 data URI several megabytes long. + ## Serialization -JSON and MessagePack are both supported, selected with `Format`. Everything in this document behaves -the same on either. +JSON and MessagePack are both supported, selected with `Format`. The serializer is chosen per +connection, so changing `Format` at runtime negotiates the new sub-protocol on the next connection. + +Everything in this document behaves the same on either. That is a design goal rather than a +guarantee the compiler can make, which is why both are exercised against a real OBS on every change +rather than only under unit tests. ## Example app @@ -649,12 +728,32 @@ The validation run covers the settings helpers, event streams, `WaitForEventAsyn builder, the raw path, typed enums, screenshots and handles. It also calls every read request and every safely sendable write request, and fails on any response it cannot deserialize. +It is a single self-contained command, prints a verdict and exits non-zero if any check failed, so +it can be run by hand or used as a gate. Point it at an OBS with configuration, environment +variables or command-line arguments, in that order of precedence: + +```bash +Obs__ServerUri=ws://localhost:4455 Obs__Password=secret \ + dotnet run --project ObsWebSocket.Example -- run-transport-tests +``` + +The `Live OBS validation` workflow runs exactly this against an OBS it installs and starts itself. + ## Native AOT +The library is built for AOT: the protocol path is source-generated JSON with no reflection +fallback, option validation is hand written rather than DataAnnotations-based, and +`IsAotCompatible` is set for every compatible target. + ```bash dotnet publish ObsWebSocket.Example/ObsWebSocket.Example.csproj -c Release -r win-x64 --self-contained true ``` +CI publishes this sample for `linux-x64` and `win-x64` and fails on any trimming or AOT warning +raised outside MessagePack, which resolves formatters reflectively and accounts for all of them +today. Those are the warnings you will see if you publish AOT with MessagePack; `ObsWebSocket.Core` +contributes none. + ## Contributing See [`CONTRIBUTING.md`](CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md index e5ce0ef..9423a03 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,12 @@ ## Supported versions -Only the latest released version of ObsWebSocket receives security fixes. This project is pre-1.0, so -fixes land on the current minor rather than being backported. +Only the latest published version of ObsWebSocket receives security fixes, and while this project is +pre-1.0 that version is a prerelease. Concretely: the newest package on NuGet is what gets fixed, +whether or not it is marked stable, fixes land on the current minor, and nothing is backported to an +earlier one. If you are pinned to an older version, the fix for a report will be to move forward. + +That will change at 1.0, when a stable line exists to support. ## Reporting a vulnerability diff --git a/global.json b/global.json index 6827f37..28f2ffb 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,8 @@ { "sdk": { - "version": "11.0.100-preview.7.26381.103", + "version": "11.0.100-rc.1.26425.128", "allowPrerelease": true, - "rollForward": "latestPreview" + "rollForward": "disable" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/protocol.json b/protocol.json new file mode 100644 index 0000000..e64d216 --- /dev/null +++ b/protocol.json @@ -0,0 +1,7243 @@ +{ + "enums": [ + { + "enumType": "EventSubscription", + "enumIdentifiers": [ + { + "description": "Subcription value used to disable all events.", + "enumIdentifier": "None", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "Subscription value to receive events in the `General` category.", + "enumIdentifier": "General", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 0)" + }, + { + "description": "Subscription value to receive events in the `Config` category.", + "enumIdentifier": "Config", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 1)" + }, + { + "description": "Subscription value to receive events in the `Scenes` category.", + "enumIdentifier": "Scenes", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 2)" + }, + { + "description": "Subscription value to receive events in the `Inputs` category.", + "enumIdentifier": "Inputs", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 3)" + }, + { + "description": "Subscription value to receive events in the `Transitions` category.", + "enumIdentifier": "Transitions", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 4)" + }, + { + "description": "Subscription value to receive events in the `Filters` category.", + "enumIdentifier": "Filters", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 5)" + }, + { + "description": "Subscription value to receive events in the `Outputs` category.", + "enumIdentifier": "Outputs", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 6)" + }, + { + "description": "Subscription value to receive events in the `SceneItems` category.", + "enumIdentifier": "SceneItems", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 7)" + }, + { + "description": "Subscription value to receive events in the `MediaInputs` category.", + "enumIdentifier": "MediaInputs", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 8)" + }, + { + "description": "Subscription value to receive the `VendorEvent` event.", + "enumIdentifier": "Vendors", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 9)" + }, + { + "description": "Subscription value to receive events in the `Ui` category.", + "enumIdentifier": "Ui", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 10)" + }, + { + "description": "Subscription value to receive events in the `Canvases` category.", + "enumIdentifier": "Canvases", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.7.0", + "enumValue": "(1 << 11)" + }, + { + "description": "Helper to receive all non-high-volume events.", + "enumIdentifier": "All", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(General | Config | Scenes | Inputs | Transitions | Filters | Outputs | SceneItems | MediaInputs | Vendors | Ui | Canvases)" + }, + { + "description": "Subscription value to receive the `InputVolumeMeters` high-volume event.", + "enumIdentifier": "InputVolumeMeters", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 16)" + }, + { + "description": "Subscription value to receive the `InputActiveStateChanged` high-volume event.", + "enumIdentifier": "InputActiveStateChanged", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 17)" + }, + { + "description": "Subscription value to receive the `InputShowStateChanged` high-volume event.", + "enumIdentifier": "InputShowStateChanged", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 18)" + }, + { + "description": "Subscription value to receive the `SceneItemTransformChanged` high-volume event.", + "enumIdentifier": "SceneItemTransformChanged", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "(1 << 19)" + } + ] + }, + { + "enumType": "RequestBatchExecutionType", + "enumIdentifiers": [ + { + "description": "Not a request batch.", + "enumIdentifier": "None", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "-1" + }, + { + "description": "A request batch which processes all requests serially, as fast as possible.\n\nNote: To introduce artificial delay, use the `Sleep` request and the `sleepMillis` request field.", + "enumIdentifier": "SerialRealtime", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "A request batch type which processes all requests serially, in sync with the graphics thread. Designed to provide high accuracy for animations.\n\nNote: To introduce artificial delay, use the `Sleep` request and the `sleepFrames` request field.", + "enumIdentifier": "SerialFrame", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 1 + }, + { + "description": "A request batch type which processes all requests using all available threads in the thread pool.\n\nNote: This is mainly experimental, and only really shows its colors during requests which require lots of\nactive processing, like `GetSourceScreenshot`.", + "enumIdentifier": "Parallel", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 2 + } + ] + }, + { + "enumType": "RequestStatus", + "enumIdentifiers": [ + { + "description": "Unknown status, should never be used.", + "enumIdentifier": "Unknown", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "For internal use to signify a successful field check.", + "enumIdentifier": "NoError", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 10 + }, + { + "description": "The request has succeeded.", + "enumIdentifier": "Success", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 100 + }, + { + "description": "The `requestType` field is missing from the request data.", + "enumIdentifier": "MissingRequestType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 203 + }, + { + "description": "The request type is invalid or does not exist.", + "enumIdentifier": "UnknownRequestType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 204 + }, + { + "description": "Generic error code.\n\nNote: A comment is required to be provided by obs-websocket.", + "enumIdentifier": "GenericError", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 205 + }, + { + "description": "The request batch execution type is not supported.", + "enumIdentifier": "UnsupportedRequestBatchExecutionType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 206 + }, + { + "description": "The server is not ready to handle the request.\n\nNote: This usually occurs during OBS scene collection change or exit. Requests may be tried again after a delay if this code is given.", + "enumIdentifier": "NotReady", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.3.0", + "enumValue": 207 + }, + { + "description": "A required request field is missing.", + "enumIdentifier": "MissingRequestField", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 300 + }, + { + "description": "The request does not have a valid requestData object.", + "enumIdentifier": "MissingRequestData", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 301 + }, + { + "description": "Generic invalid request field message.\n\nNote: A comment is required to be provided by obs-websocket.", + "enumIdentifier": "InvalidRequestField", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 400 + }, + { + "description": "A request field has the wrong data type.", + "enumIdentifier": "InvalidRequestFieldType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 401 + }, + { + "description": "A request field (number) is outside of the allowed range.", + "enumIdentifier": "RequestFieldOutOfRange", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 402 + }, + { + "description": "A request field (string or array) is empty and cannot be.", + "enumIdentifier": "RequestFieldEmpty", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 403 + }, + { + "description": "There are too many request fields (eg. a request takes two optionals, where only one is allowed at a time).", + "enumIdentifier": "TooManyRequestFields", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 404 + }, + { + "description": "An output is running and cannot be in order to perform the request.", + "enumIdentifier": "OutputRunning", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 500 + }, + { + "description": "An output is not running and should be.", + "enumIdentifier": "OutputNotRunning", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 501 + }, + { + "description": "An output is paused and should not be.", + "enumIdentifier": "OutputPaused", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 502 + }, + { + "description": "An output is not paused and should be.", + "enumIdentifier": "OutputNotPaused", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 503 + }, + { + "description": "An output is disabled and should not be.", + "enumIdentifier": "OutputDisabled", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 504 + }, + { + "description": "Studio mode is active and cannot be.", + "enumIdentifier": "StudioModeActive", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 505 + }, + { + "description": "Studio mode is not active and should be.", + "enumIdentifier": "StudioModeNotActive", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 506 + }, + { + "description": "The resource was not found.\n\nNote: Resources are any kind of object in obs-websocket, like inputs, profiles, outputs, etc.", + "enumIdentifier": "ResourceNotFound", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 600 + }, + { + "description": "The resource already exists.", + "enumIdentifier": "ResourceAlreadyExists", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 601 + }, + { + "description": "The type of resource found is invalid.", + "enumIdentifier": "InvalidResourceType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 602 + }, + { + "description": "There are not enough instances of the resource in order to perform the request.", + "enumIdentifier": "NotEnoughResources", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 603 + }, + { + "description": "The state of the resource is invalid. For example, if the resource is blocked from being accessed.", + "enumIdentifier": "InvalidResourceState", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 604 + }, + { + "description": "The specified input (obs_source_t-OBS_SOURCE_TYPE_INPUT) had the wrong kind.", + "enumIdentifier": "InvalidInputKind", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 605 + }, + { + "description": "The resource does not support being configured.\n\nThis is particularly relevant to transitions, where they do not always have changeable settings.", + "enumIdentifier": "ResourceNotConfigurable", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 606 + }, + { + "description": "The specified filter (obs_source_t-OBS_SOURCE_TYPE_FILTER) had the wrong kind.", + "enumIdentifier": "InvalidFilterKind", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 607 + }, + { + "description": "Creating the resource failed.", + "enumIdentifier": "ResourceCreationFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 700 + }, + { + "description": "Performing an action on the resource failed.", + "enumIdentifier": "ResourceActionFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 701 + }, + { + "description": "Processing the request failed unexpectedly.\n\nNote: A comment is required to be provided by obs-websocket.", + "enumIdentifier": "RequestProcessingFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 702 + }, + { + "description": "The combination of request fields cannot be used to perform an action.", + "enumIdentifier": "CannotAct", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 703 + } + ] + }, + { + "enumType": "ObsOutputState", + "enumIdentifiers": [ + { + "description": "Unknown state.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_UNKNOWN", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_UNKNOWN" + }, + { + "description": "The output is starting.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STARTING", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STARTING" + }, + { + "description": "The input has started.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STARTED", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STARTED" + }, + { + "description": "The output is stopping.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STOPPING", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STOPPING" + }, + { + "description": "The output has stopped.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_STOPPED", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_STOPPED" + }, + { + "description": "The output has disconnected and is reconnecting.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_RECONNECTING", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_RECONNECTING" + }, + { + "description": "The output has reconnected successfully.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_RECONNECTED", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.1.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_RECONNECTED" + }, + { + "description": "The output is now paused.", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_PAUSED", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.1.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_PAUSED" + }, + { + "description": "The output has been resumed (unpaused).", + "enumIdentifier": "OBS_WEBSOCKET_OUTPUT_RESUMED", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_OUTPUT_RESUMED" + } + ] + }, + { + "enumType": "ObsMediaInputAction", + "enumIdentifiers": [ + { + "description": "No action.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NONE", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NONE" + }, + { + "description": "Play the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY" + }, + { + "description": "Pause the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE" + }, + { + "description": "Stop the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP" + }, + { + "description": "Restart the media input.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART" + }, + { + "description": "Go to the next playlist item.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NEXT" + }, + { + "description": "Go to the previous playlist item.", + "enumIdentifier": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PREVIOUS" + } + ] + }, + { + "enumType": "WebSocketCloseCode", + "enumIdentifiers": [ + { + "description": "For internal use only to tell the request handler not to perform any close action.", + "enumIdentifier": "DontClose", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "Unknown reason, should never be used.", + "enumIdentifier": "UnknownReason", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4000 + }, + { + "description": "The server was unable to decode the incoming websocket message.", + "enumIdentifier": "MessageDecodeError", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4002 + }, + { + "description": "A data field is required but missing from the payload.", + "enumIdentifier": "MissingDataField", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4003 + }, + { + "description": "A data field's value type is invalid.", + "enumIdentifier": "InvalidDataFieldType", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4004 + }, + { + "description": "A data field's value is invalid.", + "enumIdentifier": "InvalidDataFieldValue", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4005 + }, + { + "description": "The specified `op` was invalid or missing.", + "enumIdentifier": "UnknownOpCode", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4006 + }, + { + "description": "The client sent a websocket message without first sending `Identify` message.", + "enumIdentifier": "NotIdentified", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4007 + }, + { + "description": "The client sent an `Identify` message while already identified.\n\nNote: Once a client has identified, only `Reidentify` may be used to change session parameters.", + "enumIdentifier": "AlreadyIdentified", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4008 + }, + { + "description": "The authentication attempt (via `Identify`) failed.", + "enumIdentifier": "AuthenticationFailed", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4009 + }, + { + "description": "The server detected the usage of an old version of the obs-websocket RPC protocol.", + "enumIdentifier": "UnsupportedRpcVersion", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4010 + }, + { + "description": "The websocket session has been invalidated by the obs-websocket server.\n\nNote: This is the code used by the `Kick` button in the UI Session List. If you receive this code, you must not automatically reconnect.", + "enumIdentifier": "SessionInvalidated", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4011 + }, + { + "description": "A requested feature is not supported due to hardware/software limitations.", + "enumIdentifier": "UnsupportedFeature", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 4012 + } + ] + }, + { + "enumType": "WebSocketOpCode", + "enumIdentifiers": [ + { + "description": "The initial message sent by obs-websocket to newly connected clients.", + "enumIdentifier": "Hello", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 0 + }, + { + "description": "The message sent by a newly connected client to obs-websocket in response to a `Hello`.", + "enumIdentifier": "Identify", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 1 + }, + { + "description": "The response sent by obs-websocket to a client after it has successfully identified with obs-websocket.", + "enumIdentifier": "Identified", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 2 + }, + { + "description": "The message sent by an already-identified client to update identification parameters.", + "enumIdentifier": "Reidentify", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 3 + }, + { + "description": "The message sent by obs-websocket containing an event payload.", + "enumIdentifier": "Event", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 5 + }, + { + "description": "The message sent by a client to obs-websocket to perform a request.", + "enumIdentifier": "Request", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 6 + }, + { + "description": "The message sent by obs-websocket in response to a particular request from a client.", + "enumIdentifier": "RequestResponse", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 7 + }, + { + "description": "The message sent by a client to obs-websocket to perform a batch of requests.", + "enumIdentifier": "RequestBatch", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 8 + }, + { + "description": "The message sent by obs-websocket in response to a particular batch of requests from a client.", + "enumIdentifier": "RequestBatchResponse", + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "enumValue": 9 + } + ] + } + ], + "requests": [ + { + "description": "Gets an array of canvases in OBS.", + "requestType": "GetCanvasList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.7.0", + "category": "canvases", + "requestFields": [], + "responseFields": [ + { + "valueName": "canvases", + "valueType": "Array", + "valueDescription": "Array of canvases" + } + ] + }, + { + "description": "Gets the value of a \"slot\" from the selected persistent data realm.", + "requestType": "GetPersistentData", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "realm", + "valueType": "String", + "valueDescription": "The data realm to select. `OBS_WEBSOCKET_DATA_REALM_GLOBAL` or `OBS_WEBSOCKET_DATA_REALM_PROFILE`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "slotName", + "valueType": "String", + "valueDescription": "The name of the slot to retrieve data from", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "slotValue", + "valueType": "Any", + "valueDescription": "Value associated with the slot. `null` if not set" + } + ] + }, + { + "description": "Sets the value of a \"slot\" from the selected persistent data realm.", + "requestType": "SetPersistentData", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "realm", + "valueType": "String", + "valueDescription": "The data realm to select. `OBS_WEBSOCKET_DATA_REALM_GLOBAL` or `OBS_WEBSOCKET_DATA_REALM_PROFILE`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "slotName", + "valueType": "String", + "valueDescription": "The name of the slot to retrieve data from", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "slotValue", + "valueType": "Any", + "valueDescription": "The value to apply to the slot", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all scene collections", + "requestType": "GetSceneCollectionList", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentSceneCollectionName", + "valueType": "String", + "valueDescription": "The name of the current scene collection" + }, + { + "valueName": "sceneCollections", + "valueType": "Array", + "valueDescription": "Array of all available scene collections" + } + ] + }, + { + "description": "Switches to a scene collection.\n\nNote: This will block until the collection has finished changing.", + "requestType": "SetCurrentSceneCollection", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name of the scene collection to switch to", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Creates a new scene collection, switching to it in the process.\n\nNote: This will block until the collection has finished changing.", + "requestType": "CreateSceneCollection", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name for the new scene collection", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all profiles", + "requestType": "GetProfileList", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentProfileName", + "valueType": "String", + "valueDescription": "The name of the current profile" + }, + { + "valueName": "profiles", + "valueType": "Array", + "valueDescription": "Array of all available profiles" + } + ] + }, + { + "description": "Switches to a profile.", + "requestType": "SetCurrentProfile", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the profile to switch to", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Creates a new profile, switching to it in the process", + "requestType": "CreateProfile", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name for the new profile", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Removes a profile. If the current profile is chosen, it will change to a different profile first.", + "requestType": "RemoveProfile", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the profile to remove", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets a parameter from the current profile's configuration.", + "requestType": "GetProfileParameter", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "parameterCategory", + "valueType": "String", + "valueDescription": "Category of the parameter to get", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "parameterName", + "valueType": "String", + "valueDescription": "Name of the parameter to get", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "parameterValue", + "valueType": "String", + "valueDescription": "Value associated with the parameter. `null` if not set and no default" + }, + { + "valueName": "defaultParameterValue", + "valueType": "String", + "valueDescription": "Default value associated with the parameter. `null` if no default" + } + ] + }, + { + "description": "Sets the value of a parameter in the current profile's configuration.", + "requestType": "SetProfileParameter", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "parameterCategory", + "valueType": "String", + "valueDescription": "Category of the parameter to set", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "parameterName", + "valueType": "String", + "valueDescription": "Name of the parameter to set", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "parameterValue", + "valueType": "String", + "valueDescription": "Value of the parameter to set. Use `null` to delete", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the current video settings.\n\nNote: To get the true FPS value, divide the FPS numerator by the FPS denominator. Example: `60000/1001`", + "requestType": "GetVideoSettings", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "fpsNumerator", + "valueType": "Number", + "valueDescription": "Numerator of the fractional FPS value" + }, + { + "valueName": "fpsDenominator", + "valueType": "Number", + "valueDescription": "Denominator of the fractional FPS value" + }, + { + "valueName": "baseWidth", + "valueType": "Number", + "valueDescription": "Width of the base (canvas) resolution in pixels" + }, + { + "valueName": "baseHeight", + "valueType": "Number", + "valueDescription": "Height of the base (canvas) resolution in pixels" + }, + { + "valueName": "outputWidth", + "valueType": "Number", + "valueDescription": "Width of the output resolution in pixels" + }, + { + "valueName": "outputHeight", + "valueType": "Number", + "valueDescription": "Height of the output resolution in pixels" + } + ] + }, + { + "description": "Sets the current video settings.\n\nNote: Fields must be specified in pairs. For example, you cannot set only `baseWidth` without needing to specify `baseHeight`.", + "requestType": "SetVideoSettings", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "fpsNumerator", + "valueType": "Number", + "valueDescription": "Numerator of the fractional FPS value", + "valueRestrictions": ">= 1", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "fpsDenominator", + "valueType": "Number", + "valueDescription": "Denominator of the fractional FPS value", + "valueRestrictions": ">= 1", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "baseWidth", + "valueType": "Number", + "valueDescription": "Width of the base (canvas) resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "baseHeight", + "valueType": "Number", + "valueDescription": "Height of the base (canvas) resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "outputWidth", + "valueType": "Number", + "valueDescription": "Width of the output resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + }, + { + "valueName": "outputHeight", + "valueType": "Number", + "valueDescription": "Height of the output resolution in pixels", + "valueRestrictions": ">= 1, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Not changed" + } + ], + "responseFields": [] + }, + { + "description": "Gets the current stream service settings (stream destination).", + "requestType": "GetStreamServiceSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "streamServiceType", + "valueType": "String", + "valueDescription": "Stream service type, like `rtmp_custom` or `rtmp_common`" + }, + { + "valueName": "streamServiceSettings", + "valueType": "Object", + "valueDescription": "Stream service settings" + } + ] + }, + { + "description": "Sets the current stream service settings (stream destination).\n\nNote: Simple RTMP settings can be set with type `rtmp_custom` and the settings fields `server` and `key`.", + "requestType": "SetStreamServiceSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [ + { + "valueName": "streamServiceType", + "valueType": "String", + "valueDescription": "Type of stream service to apply. Example: `rtmp_common` or `rtmp_custom`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "streamServiceSettings", + "valueType": "Object", + "valueDescription": "Settings to apply to the service", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the current directory that the record output is set to.", + "requestType": "GetRecordDirectory", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "requestFields": [], + "responseFields": [ + { + "valueName": "recordDirectory", + "valueType": "String", + "valueDescription": "Output directory" + } + ] + }, + { + "description": "Sets the current directory that the record output writes files to.", + "requestType": "SetRecordDirectory", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.3.0", + "category": "config", + "requestFields": [ + { + "valueName": "recordDirectory", + "valueType": "String", + "valueDescription": "Output directory", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all available source filter kinds.\n\nSimilar to `GetInputKindList`", + "requestType": "GetSourceFilterKindList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.4.0", + "category": "filters", + "requestFields": [], + "responseFields": [ + { + "valueName": "sourceFilterKinds", + "valueType": "Array", + "valueDescription": "Array of source filter kinds" + } + ] + }, + { + "description": "Gets an array of all of a source's filters.", + "requestType": "GetSourceFilterList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "filters", + "valueType": "Array", + "valueDescription": "Array of filters" + } + ] + }, + { + "description": "Gets the default settings for a filter kind.", + "requestType": "GetSourceFilterDefaultSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "Filter kind to get the default settings for", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "defaultFilterSettings", + "valueType": "Object", + "valueDescription": "Object of default settings for the filter kind" + } + ] + }, + { + "description": "Creates a new filter, adding it to the specified source.", + "requestType": "CreateSourceFilter", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to add the filter to", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source to add the filter to", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the new filter to be created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "The kind of filter to be created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "Settings object to initialize the filter with", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Default settings used" + } + ], + "responseFields": [] + }, + { + "description": "Removes a filter from a source.", + "requestType": "RemoveSourceFilter", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter to remove", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the name of a source filter (rename).", + "requestType": "SetSourceFilterName", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Current name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "newFilterName", + "valueType": "String", + "valueDescription": "New name for the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the info for a specific source filter.", + "requestType": "GetSourceFilter", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "filterEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the filter is enabled" + }, + { + "valueName": "filterIndex", + "valueType": "Number", + "valueDescription": "Index of the filter in the list, beginning at 0" + }, + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "The kind of filter" + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "Settings object associated with the filter" + } + ] + }, + { + "description": "Sets the index position of a filter on a source.", + "requestType": "SetSourceFilterIndex", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterIndex", + "valueType": "Number", + "valueDescription": "New index position of the filter", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the settings of a source filter.", + "requestType": "SetSourceFilterSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter to set the settings of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "Object of settings to apply", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "overlay", + "valueType": "Boolean", + "valueDescription": "True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings.", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "true" + } + ], + "responseFields": [] + }, + { + "description": "Sets the enable state of a source filter.", + "requestType": "SetSourceFilterEnabled", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source the filter is on", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "filterEnabled", + "valueType": "Boolean", + "valueDescription": "New enable state of the filter", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets data about the current plugin and RPC version.", + "requestType": "GetVersion", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [], + "responseFields": [ + { + "valueName": "obsVersion", + "valueType": "String", + "valueDescription": "Current OBS Studio version" + }, + { + "valueName": "obsWebSocketVersion", + "valueType": "String", + "valueDescription": "Current obs-websocket version" + }, + { + "valueName": "rpcVersion", + "valueType": "Number", + "valueDescription": "Current latest obs-websocket RPC version" + }, + { + "valueName": "availableRequests", + "valueType": "Array", + "valueDescription": "Array of available RPC requests for the currently negotiated RPC version" + }, + { + "valueName": "supportedImageFormats", + "valueType": "Array", + "valueDescription": "Image formats available in `GetSourceScreenshot` and `SaveSourceScreenshot` requests." + }, + { + "valueName": "platform", + "valueType": "String", + "valueDescription": "Name of the platform. Usually `windows`, `macos`, or `ubuntu` (linux flavor). Not guaranteed to be any of those" + }, + { + "valueName": "platformDescription", + "valueType": "String", + "valueDescription": "Description of the platform, like `Windows 10 (10.0)`" + } + ] + }, + { + "description": "Gets statistics about OBS, obs-websocket, and the current session.", + "requestType": "GetStats", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [], + "responseFields": [ + { + "valueName": "cpuUsage", + "valueType": "Number", + "valueDescription": "Current CPU usage in percent" + }, + { + "valueName": "memoryUsage", + "valueType": "Number", + "valueDescription": "Amount of memory in MB currently being used by OBS" + }, + { + "valueName": "availableDiskSpace", + "valueType": "Number", + "valueDescription": "Available disk space on the device being used for recording storage" + }, + { + "valueName": "activeFps", + "valueType": "Number", + "valueDescription": "Current FPS being rendered" + }, + { + "valueName": "averageFrameRenderTime", + "valueType": "Number", + "valueDescription": "Average time in milliseconds that OBS is taking to render a frame" + }, + { + "valueName": "renderSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by OBS in the render thread" + }, + { + "valueName": "renderTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames outputted by the render thread" + }, + { + "valueName": "outputSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by OBS in the output thread" + }, + { + "valueName": "outputTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames outputted by the output thread" + }, + { + "valueName": "webSocketSessionIncomingMessages", + "valueType": "Number", + "valueDescription": "Total number of messages received by obs-websocket from the client" + }, + { + "valueName": "webSocketSessionOutgoingMessages", + "valueType": "Number", + "valueDescription": "Total number of messages sent by obs-websocket to the client" + } + ] + }, + { + "description": "Broadcasts a `CustomEvent` to all WebSocket clients. Receivers are clients which are identified and subscribed.", + "requestType": "BroadcastCustomEvent", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "eventData", + "valueType": "Object", + "valueDescription": "Data payload to emit to all receivers", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Call a request registered to a vendor.\n\nA vendor is a unique name registered by a third-party plugin or script, which allows for custom requests and events to be added to obs-websocket.\nIf a plugin or script implements vendor requests or events, documentation is expected to be provided with them.", + "requestType": "CallVendorRequest", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "vendorName", + "valueType": "String", + "valueDescription": "Name of the vendor to use", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "requestType", + "valueType": "String", + "valueDescription": "The request type to call", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "requestData", + "valueType": "Object", + "valueDescription": "Object containing appropriate request data", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "{}" + } + ], + "responseFields": [ + { + "valueName": "vendorName", + "valueType": "String", + "valueDescription": "Echoed of `vendorName`" + }, + { + "valueName": "requestType", + "valueType": "String", + "valueDescription": "Echoed of `requestType`" + }, + { + "valueName": "responseData", + "valueType": "Object", + "valueDescription": "Object containing appropriate response data. {} if request does not provide any response data" + } + ] + }, + { + "description": "Gets an array of all hotkey names in OBS.\n\nNote: Hotkey functionality in obs-websocket comes as-is, and we do not guarantee support if things are broken. In 9/10 usages of hotkey requests, there exists a better, more reliable method via other requests.", + "requestType": "GetHotkeyList", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [], + "responseFields": [ + { + "valueName": "hotkeys", + "valueType": "Array", + "valueDescription": "Array of hotkey names" + } + ] + }, + { + "description": "Triggers a hotkey using its name. See `GetHotkeyList`.\n\nNote: Hotkey functionality in obs-websocket comes as-is, and we do not guarantee support if things are broken. In 9/10 usages of hotkey requests, there exists a better, more reliable method via other requests.", + "requestType": "TriggerHotkeyByName", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "hotkeyName", + "valueType": "String", + "valueDescription": "Name of the hotkey to trigger", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "contextName", + "valueType": "String", + "valueDescription": "Name of context of the hotkey to trigger", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Triggers a hotkey using a sequence of keys.\n\nNote: Hotkey functionality in obs-websocket comes as-is, and we do not guarantee support if things are broken. In 9/10 usages of hotkey requests, there exists a better, more reliable method via other requests.", + "requestType": "TriggerHotkeyByKeySequence", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "keyId", + "valueType": "String", + "valueDescription": "The OBS key ID to use. See https://github.com/obsproject/obs-studio/blob/master/libobs/obs-hotkeys.h", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers", + "valueType": "Object", + "valueDescription": "Object containing key modifiers to apply", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Ignored" + }, + { + "valueName": "keyModifiers.shift", + "valueType": "Boolean", + "valueDescription": "Press Shift", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers.control", + "valueType": "Boolean", + "valueDescription": "Press CTRL", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers.alt", + "valueType": "Boolean", + "valueDescription": "Press ALT", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + }, + { + "valueName": "keyModifiers.command", + "valueType": "Boolean", + "valueDescription": "Press CMD (Mac)", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Not pressed" + } + ], + "responseFields": [] + }, + { + "description": "Sleeps for a time duration or number of frames. Only available in request batches with types `SERIAL_REALTIME` or `SERIAL_FRAME`.", + "requestType": "Sleep", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "requestFields": [ + { + "valueName": "sleepMillis", + "valueType": "Number", + "valueDescription": "Number of milliseconds to sleep for (if `SERIAL_REALTIME` mode)", + "valueRestrictions": ">= 0, <= 50000", + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sleepFrames", + "valueType": "Number", + "valueDescription": "Number of frames to sleep for (if `SERIAL_FRAME` mode)", + "valueRestrictions": ">= 0, <= 10000", + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all inputs in OBS.", + "requestType": "GetInputList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "Restrict the array to only inputs of the specified kind", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "All kinds included" + } + ], + "responseFields": [ + { + "valueName": "inputs", + "valueType": "Array", + "valueDescription": "Array of inputs" + } + ] + }, + { + "description": "Gets an array of all available input kinds in OBS.", + "requestType": "GetInputKindList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "unversioned", + "valueType": "Boolean", + "valueDescription": "True == Return all kinds as unversioned, False == Return with version suffixes (if available)", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "false" + } + ], + "responseFields": [ + { + "valueName": "inputKinds", + "valueType": "Array", + "valueDescription": "Array of input kinds" + } + ] + }, + { + "description": "Gets the names of all special inputs.", + "requestType": "GetSpecialInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "desktop1", + "valueType": "String", + "valueDescription": "Name of the Desktop Audio input" + }, + { + "valueName": "desktop2", + "valueType": "String", + "valueDescription": "Name of the Desktop Audio 2 input" + }, + { + "valueName": "mic1", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio input" + }, + { + "valueName": "mic2", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio 2 input" + }, + { + "valueName": "mic3", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio 3 input" + }, + { + "valueName": "mic4", + "valueType": "String", + "valueDescription": "Name of the Mic/Auxiliary Audio 4 input" + } + ] + }, + { + "description": "Creates a new input, adding it as a scene item to the specified scene.", + "requestType": "CreateInput", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to add the input to as a scene item", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene to add the input to as a scene item", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the new input to created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "The kind of input to be created", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "Settings object to initialize the input with", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Default settings used" + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Whether to set the created scene item to enabled or disabled", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "True" + } + ], + "responseFields": [ + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the newly created input" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "ID of the newly created scene item" + } + ] + }, + { + "description": "Removes an existing input.\n\nNote: Will immediately remove all associated scene items.", + "requestType": "RemoveInput", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to remove", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to remove", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Sets the name of an input (rename).", + "requestType": "SetInputName", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Current input name", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "Current input UUID", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "newInputName", + "valueType": "String", + "valueDescription": "New name for the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the default settings for an input kind.", + "requestType": "GetInputDefaultSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "Input kind to get the default settings for", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "defaultInputSettings", + "valueType": "Object", + "valueDescription": "Object of default settings for the input kind" + } + ] + }, + { + "description": "Gets the settings of an input.\n\nNote: Does not include defaults. To create the entire settings object, overlay `inputSettings` over the `defaultInputSettings` provided by `GetInputDefaultSettings`.", + "requestType": "GetInputSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the settings of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to get the settings of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "Object of settings for the input" + }, + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "The kind of the input" + } + ] + }, + { + "description": "Sets the settings of an input.", + "requestType": "SetInputSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the settings of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to set the settings of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "Object of settings to apply", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "overlay", + "valueType": "Boolean", + "valueDescription": "True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings.", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "true" + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio mute state of an input.", + "requestType": "GetInputMute", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of input to get the mute state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of input to get the mute state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether the input is muted" + } + ] + }, + { + "description": "Sets the audio mute state of an input.", + "requestType": "SetInputMute", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the mute state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to set the mute state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether to mute the input or not", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Toggles the audio mute state of an input.", + "requestType": "ToggleInputMute", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to toggle the mute state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to toggle the mute state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether the input has been muted or unmuted" + } + ] + }, + { + "description": "Gets the current volume setting of an input.", + "requestType": "GetInputVolume", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the volume of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to get the volume of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputVolumeMul", + "valueType": "Number", + "valueDescription": "Volume setting in mul" + }, + { + "valueName": "inputVolumeDb", + "valueType": "Number", + "valueDescription": "Volume setting in dB" + } + ] + }, + { + "description": "Sets the volume setting of an input.", + "requestType": "SetInputVolume", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the volume of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to set the volume of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputVolumeMul", + "valueType": "Number", + "valueDescription": "Volume setting in mul", + "valueRestrictions": ">= 0, <= 20", + "valueOptional": true, + "valueOptionalBehavior": "`inputVolumeDb` should be specified" + }, + { + "valueName": "inputVolumeDb", + "valueType": "Number", + "valueDescription": "Volume setting in dB", + "valueRestrictions": ">= -100, <= 26", + "valueOptional": true, + "valueOptionalBehavior": "`inputVolumeMul` should be specified" + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio balance of an input.", + "requestType": "GetInputAudioBalance", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the audio balance of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to get the audio balance of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputAudioBalance", + "valueType": "Number", + "valueDescription": "Audio balance value from 0.0-1.0" + } + ] + }, + { + "description": "Sets the audio balance of an input.", + "requestType": "SetInputAudioBalance", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the audio balance of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to set the audio balance of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputAudioBalance", + "valueType": "Number", + "valueDescription": "New audio balance value", + "valueRestrictions": ">= 0.0, <= 1.0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio sync offset of an input.\n\nNote: The audio sync offset can be negative too!", + "requestType": "GetInputAudioSyncOffset", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the audio sync offset of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to get the audio sync offset of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputAudioSyncOffset", + "valueType": "Number", + "valueDescription": "Audio sync offset in milliseconds" + } + ] + }, + { + "description": "Sets the audio sync offset of an input.", + "requestType": "SetInputAudioSyncOffset", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the audio sync offset of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to set the audio sync offset of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputAudioSyncOffset", + "valueType": "Number", + "valueDescription": "New audio sync offset in milliseconds", + "valueRestrictions": ">= -950, <= 20000", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the audio monitor type of an input.\n\nThe available audio monitor types are:\n\n- `OBS_MONITORING_TYPE_NONE`\n- `OBS_MONITORING_TYPE_MONITOR_ONLY`\n- `OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT`", + "requestType": "GetInputAudioMonitorType", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to get the audio monitor type of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to get the audio monitor type of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "monitorType", + "valueType": "String", + "valueDescription": "Audio monitor type" + } + ] + }, + { + "description": "Sets the audio monitor type of an input.", + "requestType": "SetInputAudioMonitorType", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to set the audio monitor type of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to set the audio monitor type of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "monitorType", + "valueType": "String", + "valueDescription": "Audio monitor type", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the enable state of all audio tracks of an input.", + "requestType": "GetInputAudioTracks", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputAudioTracks", + "valueType": "Object", + "valueDescription": "Object of audio tracks and associated enable states" + } + ] + }, + { + "description": "Sets the enable state of audio tracks of an input.", + "requestType": "SetInputAudioTracks", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputAudioTracks", + "valueType": "Object", + "valueDescription": "Track settings to apply", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the deinterlace mode of an input.\n\nDeinterlace Modes:\n\n- `OBS_DEINTERLACE_MODE_DISABLE`\n- `OBS_DEINTERLACE_MODE_DISCARD`\n- `OBS_DEINTERLACE_MODE_RETRO`\n- `OBS_DEINTERLACE_MODE_BLEND`\n- `OBS_DEINTERLACE_MODE_BLEND_2X`\n- `OBS_DEINTERLACE_MODE_LINEAR`\n- `OBS_DEINTERLACE_MODE_LINEAR_2X`\n- `OBS_DEINTERLACE_MODE_YADIF`\n- `OBS_DEINTERLACE_MODE_YADIF_2X`\n\nNote: Deinterlacing functionality is restricted to async inputs only.", + "requestType": "GetInputDeinterlaceMode", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.6.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputDeinterlaceMode", + "valueType": "String", + "valueDescription": "Deinterlace mode of the input" + } + ] + }, + { + "description": "Sets the deinterlace mode of an input.\n\nNote: Deinterlacing functionality is restricted to async inputs only.", + "requestType": "SetInputDeinterlaceMode", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.6.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputDeinterlaceMode", + "valueType": "String", + "valueDescription": "Deinterlace mode for the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the deinterlace field order of an input.\n\nDeinterlace Field Orders:\n\n- `OBS_DEINTERLACE_FIELD_ORDER_TOP`\n- `OBS_DEINTERLACE_FIELD_ORDER_BOTTOM`\n\nNote: Deinterlacing functionality is restricted to async inputs only.", + "requestType": "GetInputDeinterlaceFieldOrder", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.6.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "inputDeinterlaceFieldOrder", + "valueType": "String", + "valueDescription": "Deinterlace field order of the input" + } + ] + }, + { + "description": "Sets the deinterlace field order of an input.\n\nNote: Deinterlacing functionality is restricted to async inputs only.", + "requestType": "SetInputDeinterlaceFieldOrder", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.6.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputDeinterlaceFieldOrder", + "valueType": "String", + "valueDescription": "Deinterlace field order for the input", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the items of a list property from an input's properties.\n\nNote: Use this in cases where an input provides a dynamic, selectable list of items. For example, display capture, where it provides a list of available displays.", + "requestType": "GetInputPropertiesListPropertyItems", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "propertyName", + "valueType": "String", + "valueDescription": "Name of the list property to get the items of", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "propertyItems", + "valueType": "Array", + "valueDescription": "Array of items in the list property" + } + ] + }, + { + "description": "Presses a button in the properties of an input.\n\nSome known `propertyName` values are:\n\n- `refreshnocache` - Browser source reload button\n\nNote: Use this in cases where there is a button in the properties of an input that cannot be accessed in any other way. For example, browser sources, where there is a refresh button.", + "requestType": "PressInputPropertiesButton", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "propertyName", + "valueType": "String", + "valueDescription": "Name of the button property to press", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the status of a media input.\n\nMedia States:\n\n- `OBS_MEDIA_STATE_NONE`\n- `OBS_MEDIA_STATE_PLAYING`\n- `OBS_MEDIA_STATE_OPENING`\n- `OBS_MEDIA_STATE_BUFFERING`\n- `OBS_MEDIA_STATE_PAUSED`\n- `OBS_MEDIA_STATE_STOPPED`\n- `OBS_MEDIA_STATE_ENDED`\n- `OBS_MEDIA_STATE_ERROR`", + "requestType": "GetMediaInputStatus", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "mediaState", + "valueType": "String", + "valueDescription": "State of the media input" + }, + { + "valueName": "mediaDuration", + "valueType": "Number", + "valueDescription": "Total duration of the playing media in milliseconds. `null` if not playing" + }, + { + "valueName": "mediaCursor", + "valueType": "Number", + "valueDescription": "Position of the cursor in milliseconds. `null` if not playing" + } + ] + }, + { + "description": "Sets the cursor position of a media input.\n\nThis request does not perform bounds checking of the cursor position.", + "requestType": "SetMediaInputCursor", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "mediaCursor", + "valueType": "Number", + "valueDescription": "New cursor position to set", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Offsets the current cursor position of a media input by the specified value.\n\nThis request does not perform bounds checking of the cursor position.", + "requestType": "OffsetMediaInputCursor", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "mediaCursorOffset", + "valueType": "Number", + "valueDescription": "Value to offset the current cursor position by", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Triggers an action on a media input.", + "requestType": "TriggerMediaInputAction", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the media input", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "mediaAction", + "valueType": "String", + "valueDescription": "Identifier of the `ObsMediaInputAction` enum", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the status of the virtualcam output.", + "requestType": "GetVirtualCamStatus", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Toggles the state of the virtualcam output.", + "requestType": "ToggleVirtualCam", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Starts the virtualcam output.", + "requestType": "StartVirtualCam", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the virtualcam output.", + "requestType": "StopVirtualCam", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Gets the status of the replay buffer output.", + "requestType": "GetReplayBufferStatus", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Toggles the state of the replay buffer output.", + "requestType": "ToggleReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Starts the replay buffer output.", + "requestType": "StartReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the replay buffer output.", + "requestType": "StopReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Saves the contents of the replay buffer output.", + "requestType": "SaveReplayBuffer", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Gets the filename of the last replay buffer save file.", + "requestType": "GetLastReplayBufferReplay", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "savedReplayPath", + "valueType": "String", + "valueDescription": "File path" + } + ] + }, + { + "description": "Gets the list of available outputs.", + "requestType": "GetOutputList", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputs", + "valueType": "Array", + "valueDescription": "Array of outputs" + } + ] + }, + { + "description": "Gets the status of an output.", + "requestType": "GetOutputStatus", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputReconnecting", + "valueType": "Boolean", + "valueDescription": "Whether the output is reconnecting" + }, + { + "valueName": "outputTimecode", + "valueType": "String", + "valueDescription": "Current formatted timecode string for the output" + }, + { + "valueName": "outputDuration", + "valueType": "Number", + "valueDescription": "Current duration in milliseconds for the output" + }, + { + "valueName": "outputCongestion", + "valueType": "Number", + "valueDescription": "Congestion of the output" + }, + { + "valueName": "outputBytes", + "valueType": "Number", + "valueDescription": "Number of bytes sent by the output" + }, + { + "valueName": "outputSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by the output's process" + }, + { + "valueName": "outputTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames delivered by the output's process" + } + ] + }, + { + "description": "Toggles the status of an output.", + "requestType": "ToggleOutput", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + } + ] + }, + { + "description": "Starts an output.", + "requestType": "StartOutput", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Stops an output.", + "requestType": "StopOutput", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the settings of an output.", + "requestType": "GetOutputSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "outputSettings", + "valueType": "Object", + "valueDescription": "Output settings" + } + ] + }, + { + "description": "Sets the settings of an output.", + "requestType": "SetOutputSettings", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "requestFields": [ + { + "valueName": "outputName", + "valueType": "String", + "valueDescription": "Output name", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "outputSettings", + "valueType": "Object", + "valueDescription": "Output settings", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the status of the record output.", + "requestType": "GetRecordStatus", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputPaused", + "valueType": "Boolean", + "valueDescription": "Whether the output is paused" + }, + { + "valueName": "outputTimecode", + "valueType": "String", + "valueDescription": "Current formatted timecode string for the output" + }, + { + "valueName": "outputDuration", + "valueType": "Number", + "valueDescription": "Current duration in milliseconds for the output" + }, + { + "valueName": "outputBytes", + "valueType": "Number", + "valueDescription": "Number of bytes sent by the output" + } + ] + }, + { + "description": "Toggles the status of the record output.", + "requestType": "ToggleRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "The new active state of the output" + } + ] + }, + { + "description": "Starts the record output.", + "requestType": "StartRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the record output.", + "requestType": "StopRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputPath", + "valueType": "String", + "valueDescription": "File name for the saved recording" + } + ] + }, + { + "description": "Toggles pause on the record output.", + "requestType": "ToggleRecordPause", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Pauses the record output.", + "requestType": "PauseRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Resumes the record output.", + "requestType": "ResumeRecord", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Splits the current file being recorded into a new file.", + "requestType": "SplitRecordFile", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.5.0", + "category": "record", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Adds a new chapter marker to the file currently being recorded.\n\nNote: As of OBS 30.2.0, the only file format supporting this feature is Hybrid MP4.", + "requestType": "CreateRecordChapter", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.5.0", + "category": "record", + "requestFields": [ + { + "valueName": "chapterName", + "valueType": "String", + "valueDescription": "Name of the new chapter", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Gets a list of all scene items in a scene.\n\nScenes only", + "requestType": "GetSceneItemList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to get the items of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene to get the items of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "sceneItems", + "valueType": "Array", + "valueDescription": "Array of scene items in the scene" + } + ] + }, + { + "description": "Basically GetSceneItemList, but for groups.\n\nUsing groups at all in OBS is discouraged, as they are very broken under the hood. Please use nested scenes instead.\n\nGroups only", + "requestType": "GetGroupSceneItemList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the group is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the group to get the items of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the group to get the items of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "sceneItems", + "valueType": "Array", + "valueDescription": "Array of scene items in the group" + } + ] + }, + { + "description": "Searches a scene for a source, and returns its id.\n\nScenes and Groups", + "requestType": "GetSceneItemId", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene or group is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene or group to search in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene or group to search in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to find", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "searchOffset", + "valueType": "Number", + "valueDescription": "Number of matches to skip during search. >= 0 means first forward. -1 means last (top) item", + "valueRestrictions": ">= -1", + "valueOptional": true, + "valueOptionalBehavior": "0" + } + ], + "responseFields": [ + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "Gets the source associated with a scene item.", + "requestType": "GetSceneItemSource", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.4.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source associated with the scene item" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source associated with the scene item" + } + ] + }, + { + "description": "Creates a new scene item using a source.\n\nScenes only", + "requestType": "CreateSceneItem", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to create the new item in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene to create the new item in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to add to the scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source to add to the scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Enable state to apply to the scene item on creation", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "True" + } + ], + "responseFields": [ + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "Removes a scene item from a scene.\n\nScenes only", + "requestType": "RemoveSceneItem", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Duplicates a scene item, copying all transform and crop info.\n\nScenes only", + "requestType": "DuplicateSceneItem", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "destinationSceneName", + "valueType": "String", + "valueDescription": "Name of the scene to create the duplicated item in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "From scene is assumed" + }, + { + "valueName": "destinationSceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene to create the duplicated item in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "From scene is assumed" + } + ], + "responseFields": [ + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the duplicated scene item" + } + ] + }, + { + "description": "Gets the transform and crop info of a scene item.\n\nScenes and Groups", + "requestType": "GetSceneItemTransform", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemTransform", + "valueType": "Object", + "valueDescription": "Object containing scene item transform info" + } + ] + }, + { + "description": "Sets the transform and crop info of a scene item.", + "requestType": "SetSceneItemTransform", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemTransform", + "valueType": "Object", + "valueDescription": "Object containing scene item transform info to update", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the enable state of a scene item.\n\nScenes and Groups", + "requestType": "GetSceneItemEnabled", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is enabled. `true` for enabled, `false` for disabled" + } + ] + }, + { + "description": "Sets the enable state of a scene item.\n\nScenes and Groups", + "requestType": "SetSceneItemEnabled", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "New enable state of the scene item", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the lock state of a scene item.\n\nScenes and Groups", + "requestType": "GetSceneItemLocked", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemLocked", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is locked. `true` for locked, `false` for unlocked" + } + ] + }, + { + "description": "Sets the lock state of a scene item.\n\nScenes and Group", + "requestType": "SetSceneItemLocked", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemLocked", + "valueType": "Boolean", + "valueDescription": "New lock state of the scene item", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the index position of a scene item in a scene.\n\nAn index of 0 is at the bottom of the source list in the UI.\n\nScenes and Groups", + "requestType": "GetSceneItemIndex", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemIndex", + "valueType": "Number", + "valueDescription": "Index position of the scene item" + } + ] + }, + { + "description": "Sets the index position of a scene item in a scene.\n\nScenes and Groups", + "requestType": "SetSceneItemIndex", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemIndex", + "valueType": "Number", + "valueDescription": "New index position of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the blend mode of a scene item.\n\nBlend modes:\n\n- `OBS_BLEND_NORMAL`\n- `OBS_BLEND_ADDITIVE`\n- `OBS_BLEND_SUBTRACT`\n- `OBS_BLEND_SCREEN`\n- `OBS_BLEND_MULTIPLY`\n- `OBS_BLEND_LIGHTEN`\n- `OBS_BLEND_DARKEN`\n\nScenes and Groups", + "requestType": "GetSceneItemBlendMode", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneItemBlendMode", + "valueType": "String", + "valueDescription": "Current blend mode" + } + ] + }, + { + "description": "Sets the blend mode of a scene item.\n\nScenes and Groups", + "requestType": "SetSceneItemBlendMode", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item", + "valueRestrictions": ">= 0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "sceneItemBlendMode", + "valueType": "String", + "valueDescription": "New blend mode", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of scenes in OBS.", + "requestType": "GetSceneList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scenes are in", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "currentProgramSceneName", + "valueType": "String", + "valueDescription": "Current program scene name. Can be `null` if non-main canvas or internal state desync" + }, + { + "valueName": "currentProgramSceneUuid", + "valueType": "String", + "valueDescription": "Current program scene UUID. Can be `null` if non-main canvas or internal state desync" + }, + { + "valueName": "currentPreviewSceneName", + "valueType": "String", + "valueDescription": "Current preview scene name. `null` if not in studio mode or non-main canvas" + }, + { + "valueName": "currentPreviewSceneUuid", + "valueType": "String", + "valueDescription": "Current preview scene UUID. `null` if not in studio mode or non-main canvas" + }, + { + "valueName": "scenes", + "valueType": "Array", + "valueDescription": "Array of scenes" + } + ] + }, + { + "description": "Gets an array of all groups in OBS.\n\nGroups in OBS are actually scenes, but renamed and modified. In obs-websocket, we treat them as scenes where we can.", + "requestType": "GetGroupList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [], + "responseFields": [ + { + "valueName": "groups", + "valueType": "Array", + "valueDescription": "Array of group names" + } + ] + }, + { + "description": "Gets the current program scene.\n\nNote 1: This request is slated to have the `currentProgram`-prefixed fields removed from in an upcoming RPC version.\n\nNote 2: Canvases do not have any concept of a program or preview scene, so this request does not support canvases.", + "requestType": "GetCurrentProgramScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [], + "responseFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Current program scene name" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "Current program scene UUID" + }, + { + "valueName": "currentProgramSceneName", + "valueType": "String", + "valueDescription": "Current program scene name (Deprecated)" + }, + { + "valueName": "currentProgramSceneUuid", + "valueType": "String", + "valueDescription": "Current program scene UUID (Deprecated)" + } + ] + }, + { + "description": "Sets the current program scene.", + "requestType": "SetCurrentProgramScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Scene name to set as the current program scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "Scene UUID to set as the current program scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Gets the current preview scene.\n\nOnly available when studio mode is enabled.\n\nNote: This request is slated to have the `currentPreview`-prefixed fields removed from in an upcoming RPC version.", + "requestType": "GetCurrentPreviewScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [], + "responseFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Current preview scene name" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "Current preview scene UUID" + }, + { + "valueName": "currentPreviewSceneName", + "valueType": "String", + "valueDescription": "Current preview scene name" + }, + { + "valueName": "currentPreviewSceneUuid", + "valueType": "String", + "valueDescription": "Current preview scene UUID" + } + ] + }, + { + "description": "Sets the current preview scene.\n\nOnly available when studio mode is enabled.", + "requestType": "SetCurrentPreviewScene", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Scene name to set as the current preview scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "Scene UUID to set as the current preview scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Creates a new scene in OBS.", + "requestType": "CreateScene", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas to create the new scene in. Leave default to assume main canvas", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name for the new scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [ + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the created scene" + } + ] + }, + { + "description": "Removes a scene from OBS.", + "requestType": "RemoveScene", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to remove", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene to remove", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Sets the name of a scene (rename).", + "requestType": "SetSceneName", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene to be renamed", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene to be renamed", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "newSceneName", + "valueType": "String", + "valueDescription": "New name for the scene", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets the scene transition overridden for a scene.\n\nNote: A transition UUID response field is not currently able to be implemented as of 2024-1-18.", + "requestType": "GetSceneSceneTransitionOverride", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the overridden scene transition, else `null`" + }, + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Duration of the overridden scene transition, else `null`" + } + ] + }, + { + "description": "Sets the scene transition overridden for a scene.", + "requestType": "SetSceneSceneTransitionOverride", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the scene is in, if using the sceneName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the scene transition to use as override. Specify `null` to remove", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unchanged" + }, + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Duration to use for any overridden transition. Specify `null` to remove", + "valueRestrictions": ">= 50, <= 20000", + "valueOptional": true, + "valueOptionalBehavior": "Unchanged" + } + ], + "responseFields": [] + }, + { + "description": "Gets the active and show state of a source.\n\n**Compatible with inputs and scenes.**", + "requestType": "GetSourceActive", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "sources", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to get the active state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source to get the active state of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [ + { + "valueName": "videoActive", + "valueType": "Boolean", + "valueDescription": "Whether the source is showing in Program" + }, + { + "valueName": "videoShowing", + "valueType": "Boolean", + "valueDescription": "Whether the source is showing in the UI (Preview, Projector, Properties)" + } + ] + }, + { + "description": "Gets a Base64-encoded screenshot of a source.\n\nThe `imageWidth` and `imageHeight` parameters are treated as \"scale to inner\", meaning the smallest ratio will be used and the aspect ratio of the original resolution is kept.\nIf `imageWidth` and `imageHeight` are not specified, the compressed image will use the full resolution of the source.\n\n**Compatible with inputs and scenes.**", + "requestType": "GetSourceScreenshot", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "sources", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to take a screenshot of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source to take a screenshot of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "imageFormat", + "valueType": "String", + "valueDescription": "Image compression format to use. Use `GetVersion` to get compatible image formats", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageWidth", + "valueType": "Number", + "valueDescription": "Width to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageHeight", + "valueType": "Number", + "valueDescription": "Height to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageCompressionQuality", + "valueType": "Number", + "valueDescription": "Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use \"default\" (whatever that means, idk)", + "valueRestrictions": ">= -1, <= 100", + "valueOptional": true, + "valueOptionalBehavior": "-1" + } + ], + "responseFields": [ + { + "valueName": "imageData", + "valueType": "String", + "valueDescription": "Base64-encoded screenshot" + } + ] + }, + { + "description": "Saves a screenshot of a source to the filesystem.\n\nThe `imageWidth` and `imageHeight` parameters are treated as \"scale to inner\", meaning the smallest ratio will be used and the aspect ratio of the original resolution is kept.\nIf `imageWidth` and `imageHeight` are not specified, the compressed image will use the full resolution of the source.\n\n**Compatible with inputs and scenes.**", + "requestType": "SaveSourceScreenshot", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "sources", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to take a screenshot of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source to take a screenshot of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "imageFormat", + "valueType": "String", + "valueDescription": "Image compression format to use. Use `GetVersion` to get compatible image formats", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageFilePath", + "valueType": "String", + "valueDescription": "Path to save the screenshot file to. Eg. `C:\\Users\\user\\Desktop\\screenshot.png`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "imageWidth", + "valueType": "Number", + "valueDescription": "Width to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageHeight", + "valueType": "Number", + "valueDescription": "Height to scale the screenshot to", + "valueRestrictions": ">= 8, <= 4096", + "valueOptional": true, + "valueOptionalBehavior": "Source value is used" + }, + { + "valueName": "imageCompressionQuality", + "valueType": "Number", + "valueDescription": "Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use \"default\" (whatever that means, idk)", + "valueRestrictions": ">= -1, <= 100", + "valueOptional": true, + "valueOptionalBehavior": "-1" + } + ], + "responseFields": [] + }, + { + "description": "Gets the status of the stream output.", + "requestType": "GetStreamStatus", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputReconnecting", + "valueType": "Boolean", + "valueDescription": "Whether the output is currently reconnecting" + }, + { + "valueName": "outputTimecode", + "valueType": "String", + "valueDescription": "Current formatted timecode string for the output" + }, + { + "valueName": "outputDuration", + "valueType": "Number", + "valueDescription": "Current duration in milliseconds for the output" + }, + { + "valueName": "outputCongestion", + "valueType": "Number", + "valueDescription": "Congestion of the output" + }, + { + "valueName": "outputBytes", + "valueType": "Number", + "valueDescription": "Number of bytes sent by the output" + }, + { + "valueName": "outputSkippedFrames", + "valueType": "Number", + "valueDescription": "Number of frames skipped by the output's process" + }, + { + "valueName": "outputTotalFrames", + "valueType": "Number", + "valueDescription": "Total number of frames delivered by the output's process" + } + ] + }, + { + "description": "Toggles the status of the stream output.", + "requestType": "ToggleStream", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "New state of the stream output" + } + ] + }, + { + "description": "Starts the stream output.", + "requestType": "StartStream", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Stops the stream output.", + "requestType": "StopStream", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Sends CEA-608 caption text over the stream output.", + "requestType": "SendStreamCaption", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "stream", + "requestFields": [ + { + "valueName": "captionText", + "valueType": "String", + "valueDescription": "Caption text", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Gets an array of all available transition kinds.\n\nSimilar to `GetInputKindList`", + "requestType": "GetTransitionKindList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "transitionKinds", + "valueType": "Array", + "valueDescription": "Array of transition kinds" + } + ] + }, + { + "description": "Gets an array of all scene transitions in OBS.", + "requestType": "GetSceneTransitionList", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "currentSceneTransitionName", + "valueType": "String", + "valueDescription": "Name of the current scene transition. Can be null" + }, + { + "valueName": "currentSceneTransitionUuid", + "valueType": "String", + "valueDescription": "UUID of the current scene transition. Can be null" + }, + { + "valueName": "currentSceneTransitionKind", + "valueType": "String", + "valueDescription": "Kind of the current scene transition. Can be null" + }, + { + "valueName": "transitions", + "valueType": "Array", + "valueDescription": "Array of transitions" + } + ] + }, + { + "description": "Gets information about the current scene transition.", + "requestType": "GetCurrentSceneTransition", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the transition" + }, + { + "valueName": "transitionUuid", + "valueType": "String", + "valueDescription": "UUID of the transition" + }, + { + "valueName": "transitionKind", + "valueType": "String", + "valueDescription": "Kind of the transition" + }, + { + "valueName": "transitionFixed", + "valueType": "Boolean", + "valueDescription": "Whether the transition uses a fixed (unconfigurable) duration" + }, + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Configured transition duration in milliseconds. `null` if transition is fixed" + }, + { + "valueName": "transitionConfigurable", + "valueType": "Boolean", + "valueDescription": "Whether the transition supports being configured" + }, + { + "valueName": "transitionSettings", + "valueType": "Object", + "valueDescription": "Object of settings for the transition. `null` if transition is not configurable" + } + ] + }, + { + "description": "Sets the current scene transition.\n\nSmall note: While the namespace of scene transitions is generally unique, that uniqueness is not a guarantee as it is with other resources like inputs.", + "requestType": "SetCurrentSceneTransition", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the transition to make active", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the duration of the current scene transition, if it is not fixed.", + "requestType": "SetCurrentSceneTransitionDuration", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Duration in milliseconds", + "valueRestrictions": ">= 50, <= 20000", + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Sets the settings of the current scene transition.", + "requestType": "SetCurrentSceneTransitionSettings", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "transitionSettings", + "valueType": "Object", + "valueDescription": "Settings object to apply to the transition. Can be `{}`", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "overlay", + "valueType": "Boolean", + "valueDescription": "Whether to overlay over the current settings or replace them", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "true" + } + ], + "responseFields": [] + }, + { + "description": "Gets the cursor position of the current scene transition.\n\nNote: `transitionCursor` will return 1.0 when the transition is inactive.", + "requestType": "GetCurrentSceneTransitionCursor", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [ + { + "valueName": "transitionCursor", + "valueType": "Number", + "valueDescription": "Cursor position, between 0.0 and 1.0" + } + ] + }, + { + "description": "Triggers the current scene transition. Same functionality as the `Transition` button in studio mode.", + "requestType": "TriggerStudioModeTransition", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [], + "responseFields": [] + }, + { + "description": "Sets the position of the TBar.\n\n**Very important note**: This will be deprecated and replaced in a future version of obs-websocket.", + "requestType": "SetTBarPosition", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "requestFields": [ + { + "valueName": "position", + "valueType": "Number", + "valueDescription": "New position", + "valueRestrictions": ">= 0.0, <= 1.0", + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "release", + "valueType": "Boolean", + "valueDescription": "Whether to release the TBar. Only set `false` if you know that you will be sending another position update", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "`true`" + } + ], + "responseFields": [] + }, + { + "description": "Gets whether studio is enabled.", + "requestType": "GetStudioModeEnabled", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [], + "responseFields": [ + { + "valueName": "studioModeEnabled", + "valueType": "Boolean", + "valueDescription": "Whether studio mode is enabled" + } + ] + }, + { + "description": "Enables or disables studio mode", + "requestType": "SetStudioModeEnabled", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "studioModeEnabled", + "valueType": "Boolean", + "valueDescription": "True == Enabled, False == Disabled", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + } + ], + "responseFields": [] + }, + { + "description": "Opens the properties dialog of an input.", + "requestType": "OpenInputPropertiesDialog", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Opens the filters dialog of an input.", + "requestType": "OpenInputFiltersDialog", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Opens the interact dialog of an input.", + "requestType": "OpenInputInteractDialog", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input to open the dialog of", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + } + ], + "responseFields": [] + }, + { + "description": "Gets a list of connected monitors and information about them.", + "requestType": "GetMonitorList", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [], + "responseFields": [ + { + "valueName": "monitors", + "valueType": "Array", + "valueDescription": "a list of detected monitors with some information" + } + ] + }, + { + "description": "Opens a projector for a specific output video mix.\n\nMix types:\n\n- `OBS_WEBSOCKET_VIDEO_MIX_TYPE_PREVIEW`\n- `OBS_WEBSOCKET_VIDEO_MIX_TYPE_PROGRAM`\n- `OBS_WEBSOCKET_VIDEO_MIX_TYPE_MULTIVIEW`\n\nNote: This request serves to provide feature parity with 4.x. It is very likely to be changed/deprecated in a future release.", + "requestType": "OpenVideoMixProjector", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "videoMixType", + "valueType": "String", + "valueDescription": "Type of mix to open", + "valueRestrictions": null, + "valueOptional": false, + "valueOptionalBehavior": null + }, + { + "valueName": "monitorIndex", + "valueType": "Number", + "valueDescription": "Monitor index, use `GetMonitorList` to obtain index", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "-1: Opens projector in windowed mode" + }, + { + "valueName": "projectorGeometry", + "valueType": "String", + "valueDescription": "Size/Position data for a windowed projector, in Qt Base64 encoded format. Mutually exclusive with `monitorIndex`", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "N/A" + } + ], + "responseFields": [] + }, + { + "description": "Opens a projector for a source.\n\nNote: This request serves to provide feature parity with 4.x. It is very likely to be changed/deprecated in a future release.", + "requestType": "OpenSourceProjector", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "requestFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas the source is in, if using the sourceName field", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source to open a projector for", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the source to open a projector for", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "Unknown" + }, + { + "valueName": "monitorIndex", + "valueType": "Number", + "valueDescription": "Monitor index, use `GetMonitorList` to obtain index", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "-1: Opens projector in windowed mode" + }, + { + "valueName": "projectorGeometry", + "valueType": "String", + "valueDescription": "Size/Position data for a windowed projector, in Qt Base64 encoded format. Mutually exclusive with `monitorIndex`", + "valueRestrictions": null, + "valueOptional": true, + "valueOptionalBehavior": "N/A" + } + ], + "responseFields": [] + } + ], + "events": [ + { + "description": "A new canvas has been created.", + "eventType": "CanvasCreated", + "eventSubscription": "Canvases", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.7.0", + "category": "canvases", + "dataFields": [ + { + "valueName": "canvasName", + "valueType": "String", + "valueDescription": "Name of the new canvas" + }, + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the new canvas" + } + ] + }, + { + "description": "A canvas has been removed.", + "eventType": "CanvasRemoved", + "eventSubscription": "Canvases", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.7.0", + "category": "canvases", + "dataFields": [ + { + "valueName": "canvasName", + "valueType": "String", + "valueDescription": "Name of the removed canvas" + }, + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the removed canvas" + } + ] + }, + { + "description": "The name of a canvas has changed.", + "eventType": "CanvasNameChanged", + "eventSubscription": "Canvases", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.7.0", + "category": "canvases", + "dataFields": [ + { + "valueName": "canvasUuid", + "valueType": "String", + "valueDescription": "UUID of the canvas" + }, + { + "valueName": "oldCanvasName", + "valueType": "String", + "valueDescription": "Old name of the canvas" + }, + { + "valueName": "canvasName", + "valueType": "String", + "valueDescription": "New name of the canvas" + } + ] + }, + { + "description": "The current scene collection has begun changing.\n\nNote: We recommend using this event to trigger a pause of all polling requests, as performing any requests during a\nscene collection change is considered undefined behavior and can cause crashes!", + "eventType": "CurrentSceneCollectionChanging", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name of the current scene collection" + } + ] + }, + { + "description": "The current scene collection has changed.\n\nNote: If polling has been paused during `CurrentSceneCollectionChanging`, this is the que to restart polling.", + "eventType": "CurrentSceneCollectionChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "sceneCollectionName", + "valueType": "String", + "valueDescription": "Name of the new scene collection" + } + ] + }, + { + "description": "The scene collection list has changed.", + "eventType": "SceneCollectionListChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "sceneCollections", + "valueType": "Array", + "valueDescription": "Updated list of scene collections" + } + ] + }, + { + "description": "The current profile has begun changing.", + "eventType": "CurrentProfileChanging", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the current profile" + } + ] + }, + { + "description": "The current profile has changed.", + "eventType": "CurrentProfileChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "profileName", + "valueType": "String", + "valueDescription": "Name of the new profile" + } + ] + }, + { + "description": "The profile list has changed.", + "eventType": "ProfileListChanged", + "eventSubscription": "Config", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "config", + "dataFields": [ + { + "valueName": "profiles", + "valueType": "Array", + "valueDescription": "Updated list of profiles" + } + ] + }, + { + "description": "A source's filter list has been reindexed.", + "eventType": "SourceFilterListReindexed", + "eventSubscription": "Filters", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source" + }, + { + "valueName": "filters", + "valueType": "Array", + "valueDescription": "Array of filter objects" + } + ] + }, + { + "description": "A filter has been added to a source.", + "eventType": "SourceFilterCreated", + "eventSubscription": "Filters", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter was added to" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter" + }, + { + "valueName": "filterKind", + "valueType": "String", + "valueDescription": "The kind of the filter" + }, + { + "valueName": "filterIndex", + "valueType": "Number", + "valueDescription": "Index position of the filter" + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "The settings configured to the filter when it was created" + }, + { + "valueName": "defaultFilterSettings", + "valueType": "Object", + "valueDescription": "The default settings for the filter" + } + ] + }, + { + "description": "A filter has been removed from a source.", + "eventType": "SourceFilterRemoved", + "eventSubscription": "Filters", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter was on" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter" + } + ] + }, + { + "description": "The name of a source filter has changed.", + "eventType": "SourceFilterNameChanged", + "eventSubscription": "Filters", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "The source the filter is on" + }, + { + "valueName": "oldFilterName", + "valueType": "String", + "valueDescription": "Old name of the filter" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "New name of the filter" + } + ] + }, + { + "description": "An source filter's settings have changed (been updated).", + "eventType": "SourceFilterSettingsChanged", + "eventSubscription": "Filters", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.4.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter" + }, + { + "valueName": "filterSettings", + "valueType": "Object", + "valueDescription": "New settings object of the filter" + } + ] + }, + { + "description": "A source filter's enable state has changed.", + "eventType": "SourceFilterEnableStateChanged", + "eventSubscription": "Filters", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "filters", + "dataFields": [ + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the source the filter is on" + }, + { + "valueName": "filterName", + "valueType": "String", + "valueDescription": "Name of the filter" + }, + { + "valueName": "filterEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the filter is enabled" + } + ] + }, + { + "description": "OBS has begun the shutdown process.", + "eventType": "ExitStarted", + "eventSubscription": "General", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "dataFields": [] + }, + { + "description": "An input has been created.", + "eventType": "InputCreated", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "inputKind", + "valueType": "String", + "valueDescription": "The kind of the input" + }, + { + "valueName": "unversionedInputKind", + "valueType": "String", + "valueDescription": "The unversioned kind of input (aka no `_v2` stuff)" + }, + { + "valueName": "inputKindCaps", + "valueType": "Number", + "valueDescription": "Bitflag value for the caps that an input supports. See obs_source_info.output_flags in the libobs docs" + }, + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "The settings configured to the input when it was created" + }, + { + "valueName": "defaultInputSettings", + "valueType": "Object", + "valueDescription": "The default settings for the input" + } + ] + }, + { + "description": "An input has been removed.", + "eventType": "InputRemoved", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + } + ] + }, + { + "description": "The name of an input has changed.", + "eventType": "InputNameChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "oldInputName", + "valueType": "String", + "valueDescription": "Old name of the input" + }, + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "New name of the input" + } + ] + }, + { + "description": "An input's settings have changed (been updated).\n\nNote: On some inputs, changing values in the properties dialog will cause an immediate update. Pressing the \"Cancel\" button will revert the settings, resulting in another event being fired.", + "eventType": "InputSettingsChanged", + "eventSubscription": "Inputs", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.4.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "inputSettings", + "valueType": "Object", + "valueDescription": "New settings object of the input" + } + ] + }, + { + "description": "An input's active state has changed.\n\nWhen an input is active, it means it's being shown by the program feed.", + "eventType": "InputActiveStateChanged", + "eventSubscription": "InputActiveStateChanged", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "videoActive", + "valueType": "Boolean", + "valueDescription": "Whether the input is active" + } + ] + }, + { + "description": "An input's show state has changed.\n\nWhen an input is showing, it means it's being shown by the preview or a dialog.", + "eventType": "InputShowStateChanged", + "eventSubscription": "InputShowStateChanged", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "videoShowing", + "valueType": "Boolean", + "valueDescription": "Whether the input is showing" + } + ] + }, + { + "description": "An input's mute state has changed.", + "eventType": "InputMuteStateChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "inputMuted", + "valueType": "Boolean", + "valueDescription": "Whether the input is muted" + } + ] + }, + { + "description": "An input's volume level has changed.", + "eventType": "InputVolumeChanged", + "eventSubscription": "Inputs", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "inputVolumeMul", + "valueType": "Number", + "valueDescription": "New volume level multiplier" + }, + { + "valueName": "inputVolumeDb", + "valueType": "Number", + "valueDescription": "New volume level in dB" + } + ] + }, + { + "description": "The audio balance value of an input has changed.", + "eventType": "InputAudioBalanceChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "inputAudioBalance", + "valueType": "Number", + "valueDescription": "New audio balance value of the input" + } + ] + }, + { + "description": "The sync offset of an input has changed.", + "eventType": "InputAudioSyncOffsetChanged", + "eventSubscription": "Inputs", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "inputAudioSyncOffset", + "valueType": "Number", + "valueDescription": "New sync offset in milliseconds" + } + ] + }, + { + "description": "The audio tracks of an input have changed.", + "eventType": "InputAudioTracksChanged", + "eventSubscription": "Inputs", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "inputAudioTracks", + "valueType": "Object", + "valueDescription": "Object of audio tracks along with their associated enable states" + } + ] + }, + { + "description": "The monitor type of an input has changed.\n\nAvailable types are:\n\n- `OBS_MONITORING_TYPE_NONE`\n- `OBS_MONITORING_TYPE_MONITOR_ONLY`\n- `OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT`", + "eventType": "InputAudioMonitorTypeChanged", + "eventSubscription": "Inputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "monitorType", + "valueType": "String", + "valueDescription": "New monitor type of the input" + } + ] + }, + { + "description": "A high-volume event providing volume levels of all active inputs every 50 milliseconds.", + "eventType": "InputVolumeMeters", + "eventSubscription": "InputVolumeMeters", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "inputs", + "dataFields": [ + { + "valueName": "inputs", + "valueType": "Array", + "valueDescription": "Array of active inputs with their associated volume levels" + } + ] + }, + { + "description": "A media input has started playing.", + "eventType": "MediaInputPlaybackStarted", + "eventSubscription": "MediaInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + } + ] + }, + { + "description": "A media input has finished playing.", + "eventType": "MediaInputPlaybackEnded", + "eventSubscription": "MediaInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + } + ] + }, + { + "description": "An action has been performed on an input.", + "eventType": "MediaInputActionTriggered", + "eventSubscription": "MediaInputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "media inputs", + "dataFields": [ + { + "valueName": "inputName", + "valueType": "String", + "valueDescription": "Name of the input" + }, + { + "valueName": "inputUuid", + "valueType": "String", + "valueDescription": "UUID of the input" + }, + { + "valueName": "mediaAction", + "valueType": "String", + "valueDescription": "Action performed on the input. See `ObsMediaInputAction` enum" + } + ] + }, + { + "description": "The state of the stream output has changed.", + "eventType": "StreamStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + } + ] + }, + { + "description": "The state of the record output has changed.", + "eventType": "RecordStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + }, + { + "valueName": "outputPath", + "valueType": "String", + "valueDescription": "File name for the saved recording, if record stopped. `null` otherwise" + } + ] + }, + { + "description": "The record output has started writing to a new file. For example, when a file split happens.", + "eventType": "RecordFileChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.5.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "newOutputPath", + "valueType": "String", + "valueDescription": "File name that the output has begun writing to" + } + ] + }, + { + "description": "The state of the replay buffer output has changed.", + "eventType": "ReplayBufferStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + } + ] + }, + { + "description": "The state of the virtualcam output has changed.", + "eventType": "VirtualcamStateChanged", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "outputActive", + "valueType": "Boolean", + "valueDescription": "Whether the output is active" + }, + { + "valueName": "outputState", + "valueType": "String", + "valueDescription": "The specific state of the output" + } + ] + }, + { + "description": "The replay buffer has been saved.", + "eventType": "ReplayBufferSaved", + "eventSubscription": "Outputs", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "outputs", + "dataFields": [ + { + "valueName": "savedReplayPath", + "valueType": "String", + "valueDescription": "Path of the saved replay file" + } + ] + }, + { + "description": "A scene item has been created.", + "eventType": "SceneItemCreated", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item was added to" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item was added to" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the underlying source (input/scene)" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the underlying source (input/scene)" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemIndex", + "valueType": "Number", + "valueDescription": "Index position of the item" + } + ] + }, + { + "description": "A scene item has been removed.\n\nThis event is not emitted when the scene the item is in is removed.", + "eventType": "SceneItemRemoved", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item was removed from" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item was removed from" + }, + { + "valueName": "sourceName", + "valueType": "String", + "valueDescription": "Name of the underlying source (input/scene)" + }, + { + "valueName": "sourceUuid", + "valueType": "String", + "valueDescription": "UUID of the underlying source (input/scene)" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "A scene's item list has been reindexed.", + "eventType": "SceneItemListReindexed", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene" + }, + { + "valueName": "sceneItems", + "valueType": "Array", + "valueDescription": "Array of scene item objects" + } + ] + }, + { + "description": "A scene item's enable state has changed.", + "eventType": "SceneItemEnableStateChanged", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemEnabled", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is enabled (visible)" + } + ] + }, + { + "description": "A scene item's lock state has changed.", + "eventType": "SceneItemLockStateChanged", + "eventSubscription": "SceneItems", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemLocked", + "valueType": "Boolean", + "valueDescription": "Whether the scene item is locked" + } + ] + }, + { + "description": "A scene item has been selected in the Ui.", + "eventType": "SceneItemSelected", + "eventSubscription": "SceneItems", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene the item is in" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + } + ] + }, + { + "description": "The transform/crop of a scene item has changed.", + "eventType": "SceneItemTransformChanged", + "eventSubscription": "SceneItemTransformChanged", + "complexity": 4, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scene items", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "The name of the scene the item is in" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "The UUID of the scene the item is in" + }, + { + "valueName": "sceneItemId", + "valueType": "Number", + "valueDescription": "Numeric ID of the scene item" + }, + { + "valueName": "sceneItemTransform", + "valueType": "Object", + "valueDescription": "New transform/crop info of the scene item" + } + ] + }, + { + "description": "A new scene has been created.", + "eventType": "SceneCreated", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the new scene" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the new scene" + }, + { + "valueName": "isGroup", + "valueType": "Boolean", + "valueDescription": "Whether the new scene is a group" + } + ] + }, + { + "description": "A scene has been removed.", + "eventType": "SceneRemoved", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the removed scene" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the removed scene" + }, + { + "valueName": "isGroup", + "valueType": "Boolean", + "valueDescription": "Whether the scene was a group" + } + ] + }, + { + "description": "The name of a scene has changed.", + "eventType": "SceneNameChanged", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene" + }, + { + "valueName": "oldSceneName", + "valueType": "String", + "valueDescription": "Old name of the scene" + }, + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "New name of the scene" + } + ] + }, + { + "description": "The current program scene has changed.", + "eventType": "CurrentProgramSceneChanged", + "eventSubscription": "Scenes", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene that was switched to" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene that was switched to" + } + ] + }, + { + "description": "The current preview scene has changed.", + "eventType": "CurrentPreviewSceneChanged", + "eventSubscription": "Scenes", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "sceneName", + "valueType": "String", + "valueDescription": "Name of the scene that was switched to" + }, + { + "valueName": "sceneUuid", + "valueType": "String", + "valueDescription": "UUID of the scene that was switched to" + } + ] + }, + { + "description": "The list of scenes has changed.\n\nTODO: Make OBS fire this event when scenes are reordered.", + "eventType": "SceneListChanged", + "eventSubscription": "Scenes", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "scenes", + "dataFields": [ + { + "valueName": "scenes", + "valueType": "Array", + "valueDescription": "Updated array of scenes" + } + ] + }, + { + "description": "The current scene transition has changed.", + "eventType": "CurrentSceneTransitionChanged", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Name of the new transition" + }, + { + "valueName": "transitionUuid", + "valueType": "String", + "valueDescription": "UUID of the new transition" + } + ] + }, + { + "description": "The current scene transition duration has changed.", + "eventType": "CurrentSceneTransitionDurationChanged", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionDuration", + "valueType": "Number", + "valueDescription": "Transition duration in milliseconds" + } + ] + }, + { + "description": "A scene transition has started.", + "eventType": "SceneTransitionStarted", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Scene transition name" + }, + { + "valueName": "transitionUuid", + "valueType": "String", + "valueDescription": "Scene transition UUID" + } + ] + }, + { + "description": "A scene transition has completed fully.\n\nNote: Does not appear to trigger when the transition is interrupted by the user.", + "eventType": "SceneTransitionEnded", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Scene transition name" + }, + { + "valueName": "transitionUuid", + "valueType": "String", + "valueDescription": "Scene transition UUID" + } + ] + }, + { + "description": "A scene transition's video has completed fully.\n\nUseful for stinger transitions to tell when the video *actually* ends.\n`SceneTransitionEnded` only signifies the cut point, not the completion of transition playback.\n\nNote: Appears to be called by every transition, regardless of relevance.", + "eventType": "SceneTransitionVideoEnded", + "eventSubscription": "Transitions", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "transitions", + "dataFields": [ + { + "valueName": "transitionName", + "valueType": "String", + "valueDescription": "Scene transition name" + }, + { + "valueName": "transitionUuid", + "valueType": "String", + "valueDescription": "Scene transition UUID" + } + ] + }, + { + "description": "Studio mode has been enabled or disabled.", + "eventType": "StudioModeStateChanged", + "eventSubscription": "Ui", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "ui", + "dataFields": [ + { + "valueName": "studioModeEnabled", + "valueType": "Boolean", + "valueDescription": "True == Enabled, False == Disabled" + } + ] + }, + { + "description": "A screenshot has been saved.\n\nNote: Triggered for the screenshot feature available in `Settings -> Hotkeys -> Screenshot Output` ONLY.\nApplications using `Get/SaveSourceScreenshot` should implement a `CustomEvent` if this kind of inter-client\ncommunication is desired.", + "eventType": "ScreenshotSaved", + "eventSubscription": "Ui", + "complexity": 2, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.1.0", + "category": "ui", + "dataFields": [ + { + "valueName": "savedScreenshotPath", + "valueType": "String", + "valueDescription": "Path of the saved image file" + } + ] + }, + { + "description": "An event has been emitted from a vendor.\n\nA vendor is a unique name registered by a third-party plugin or script, which allows for custom requests and events to be added to obs-websocket.\nIf a plugin or script implements vendor requests or events, documentation is expected to be provided with them.", + "eventType": "VendorEvent", + "eventSubscription": "Vendors", + "complexity": 3, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "dataFields": [ + { + "valueName": "vendorName", + "valueType": "String", + "valueDescription": "Name of the vendor emitting the event" + }, + { + "valueName": "eventType", + "valueType": "String", + "valueDescription": "Vendor-provided event typedef" + }, + { + "valueName": "eventData", + "valueType": "Object", + "valueDescription": "Vendor-provided event data. {} if event does not provide any data" + } + ] + }, + { + "description": "Custom event emitted by `BroadcastCustomEvent`.", + "eventType": "CustomEvent", + "eventSubscription": "General", + "complexity": 1, + "rpcVersion": "1", + "deprecated": false, + "initialVersion": "5.0.0", + "category": "general", + "dataFields": [ + { + "valueName": "eventData", + "valueType": "Object", + "valueDescription": "Custom event data" + } + ] + } + ] +} \ No newline at end of file diff --git a/protocol.lock.json b/protocol.lock.json new file mode 100644 index 0000000..5d8b6cf --- /dev/null +++ b/protocol.lock.json @@ -0,0 +1,11 @@ +{ + "$comment": [ + "Pins the upstream revision protocol.json was taken from. The build verifies sha256 and never", + "reaches the network; only an explicit refresh does, and it rewrites this file:", + " dotnet build ObsWebSocket.Core -t:RefreshObsProtocol -p:ObsProtocolCommit=" + ], + "repository": "https://github.com/obsproject/obs-websocket", + "path": "docs/generated/protocol.json", + "commit": "bc50ba19b7007c84647bf2afbfac9ac42503fc34", + "sha256": "042e8c42da2dfd6d78fa0f695b2b59495d8254d5fe718b0f20d0e2e6f58a6993" +}