Skip to content

Repository files navigation

ObsWebSocket.Core

A .NET client for the OBS Studio WebSocket v5 protocol, with generated request types and DI-first integration.

Build Status NuGet Version License: MIT

Targets net11.0, net10.0 and net9.0. The net11.0 target is built against a .NET 11 prerelease SDK until .NET 11 is released; net10.0 and net9.0 carry no preview dependency.

Install

dotnet add package ObsWebSocket.Core

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 compatibility claims, which are not the same thing:

  • 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. 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.

Quick start

appsettings.json:

{
  "ConnectionStrings": {
    "obs": "ws://localhost:4455?password=secret"
  }
}

Program.cs:

using ObsWebSocket.Core;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.AddObsWebSocketClient("obs")    // endpoint from ConnectionStrings:obs
       .WithAutoConnect();              // connect on start, disconnect on stop
builder.Services.AddHostedService<Worker>();

await builder.Build().RunAsync();

Worker.cs:

using ObsWebSocket.Core;
using ObsWebSocket.Core.Events.Generated;

public sealed class Worker(ObsWebSocketClient client) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var version = await client.General.GetVersionAsync(ct);
        Console.WriteLine($"Connected to OBS {version.ObsVersion}");

        await client.Input("Mic").SetMuteAsync(true, ct);

        await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellationToken: ct))
        {
            Console.WriteLine($"Scene changed: {e.EventData.SceneName}");
        }
    }
}

Three ways to call OBS

Example Use it for
Handles client.Input("Mic").SetMuteAsync(true, ct) Requests about one scene, input, source, scene item or filter
Category groups client.Inputs.SetInputMuteAsync(new("Mic", true), ct) Everything. One method per protocol request, plus helpers
Raw requests client.CallAsync<T>("SetInputMute", data, ct) Requests this build does not model

Each forwards to the one below it, so they mix freely.

Category groups

The client mirrors the categories the protocol defines. Requests, event streams and the helpers this library adds sit in the group their category owns:

await client.Scenes.GetSceneListAsync(new(), ct);
await client.Scenes.SwitchProgramSceneAndWaitAsync("Intro", cancellationToken: ct);
await client.Inputs.SetInputVolumeDbAsync("Mic", -6, ct);
await client.SceneItems.SetSceneItemEnabledAsync("Intro", "Logo", false, ct);

client.Scenes.CurrentProgramSceneChanged += (_, e) => { };
await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellationToken: ct)) { break; }

The groups are Canvases, Config, Filters, General, Inputs, MediaInputs, Outputs, Record, SceneItems, Scenes, Sources, Stream, Transitions and Ui.

WaitForEventAsync and CallBatchAsync sit on the client itself, since neither belongs to a category.

Handles

Most OBS requests identify their target by name or by uuid. A handle carries that identity so it is not repeated on every call. A string is a name, a Guid is a uuid:

await client.Scene("Intro").SetCurrentProgramAsync(ct);
await client.Scene(sceneGuid).SetNameAsync("Outro", ct);
await client.Input("Mic").SetMuteAsync(true, ct);
await client.Input("Mic").Filter("EQ").SetEnabledAsync(false, ct);

The entry points are Scene, Input, Source, SceneItem and Filter. Each carries the requests the protocol defines for that kind of thing, with the entity dropped from the method name: SetSceneItemEnabled is SetEnabledAsync on a scene item, GetInputMute is GetMuteAsync on an input. The protocol name is in the XML docs and on the category group.

Requests that are not about a particular thing, such as GetVersion, GetStats and the record and stream controls, are on their group only.

Names and uuids

A name works, but breaks if the thing is renamed. Resolving a name to a uuid costs one round trip:

SceneOperations intro = await client.Scene("Intro").ResolveAsync(ct);
// intro.Handle.IsResolved is true, and a rename no longer affects it

The protocol has no lookup for a single uuid, so this reads the scene list. When the name is not found, the exception lists the names that were:

ObsWebSocketResourceNotFoundException: No scene named 'Intor'. Available: 'Intro', 'Gameplay', 'BRB'.

Handles from events and responses

Events and responses that carry a uuid expose a handle for it, so acting on one costs no extra request:

client.Scenes.CurrentProgramSceneChanged += async (_, e) =>
    await client.Scene(e.EventData.Scene).GetItemListAsync();

CreateSceneResponseData created = await client.Scenes.CreateSceneAsync(new("Intro"), ct);
await client.Scene(created.Scene).SetCurrentProgramAsync(ct);

Scene items

OBS addresses scene items by a numeric id that only GetSceneItemId reports, so an item known by source name has to be resolved before it can be used:

SceneItemOperations logo = await client.Scene("Intro").ItemAsync("Logo", cancellationToken: ct);
await logo.SetEnabledAsync(false, ct);
await logo.Scene.GetItemListAsync(ct);

await client.Scene("Intro").Item(3).SetIndexAsync(0, ct);   // an id resolves directly

Item(long) and Filter(string) send nothing, since an id and a filter name are the whole identity.

Canvases

Canvas-scoped requests take a uuid; canvasName appears only in GetCanvasList. Resolve a canvas by name to use it:

CanvasHandle vertical = await client.Canvases.ResolveAsync("Vertical", ct);
await client.Scene(vertical.Scene("Intro")).GetItemListAsync(ct);

Omitting the canvas means the main one, which is CanvasHandle.Main. A canvas scopes a name only, so a resolved handle drops it.

Helpers

Each group carries helpers for things that otherwise take several calls or a lookup. They are hand-written, so they are on the group rather than on a handle.

Typed settings helpers have two overloads: an implicit one for library-registered types, and an explicit one taking a JsonTypeInfo<T> for your own types. Use the explicit overload under Native AOT.

Settings

Helper Notes
Inputs.GetInputSettingsAsync<T> / SetInputSettingsAsync<T> Input settings; Set supports overlay
Inputs.GetInputDefaultSettingsAsync<T> Defaults for an input kind
Filters.GetSourceFilterSettingsAsync<T> / SetSourceFilterSettingsAsync<T> Filter settings; Set supports overlay
Filters.GetSourceFilterDefaultSettingsAsync<T> Defaults for a filter kind
Transitions.GetCurrentSceneTransitionSettingsAsync<T> / SetCurrentSceneTransitionSettingsAsync<T> Transition settings
Outputs.GetOutputSettingsAsync<T> / SetOutputSettingsAsync<T> Output settings
Config.GetStreamServiceSettingsAsync<T> / SetStreamServiceSettingsAsync<T> Stream service settings

Your own data

These carry data only you know the shape of, so they take a JsonTypeInfo for it.

Helper Notes
Config.GetPersistentDataAsync<T> / SetPersistentDataAsync<T> A persistent data slot; realm is OBS_WEBSOCKET_DATA_REALM_GLOBAL or OBS_WEBSOCKET_DATA_REALM_PROFILE
General.CallVendorRequestAsync<TRequest, TResponse> A request another plugin registered, typed both ways
General.BroadcastCustomEventAsync<T> A CustomEvent with your own payload

Most take optional parameters before the cancellation token, so pass it as cancellationToken: ct.

Free-form fields in events and responses

Fields the protocol leaves free-form, such as an input's settings or a vendor's reply, are JsonElement? on the payload. Each has a pair of typed readers named after the field, one for a library-registered type and one taking your JsonTypeInfo:

client.InputSettingsChanged += (_, e) =>
{
    BrowserSourceSettings? browser = e.EventData.GetInputSettings<BrowserSourceSettings>();
};

client.CustomEvent += (_, e) =>
{
    OverlaySettings? cue = e.EventData.GetEventData(MyContext.Default.OverlaySettings);
};

They work on responses too, including a batch result read with GetRequiredData. A reader returns null when OBS sent nothing, and throws ObsWebSocketSerializationException when the field has a different shape.

Scenes and scene items

  • Scenes.SwitchProgramSceneAsync(scene, ct) and Scenes.SwitchPreviewSceneAsync(scene, ct). Optional transitionName and transitionDurationMs apply to that switch only.
  • Scenes.SwitchProgramSceneAndWaitAsync and Scenes.SwitchPreviewSceneAndWaitAsync also wait for the confirming event.
  • SceneItems.SetSceneItemEnabledAsync(scene, sourceName, isEnabled, ct) returns the resulting state. Pass null for isEnabled to toggle. An overload takes the numeric item id.
  • SceneItems.FindSceneItemIdAsync(scene, sourceName, ct) returns long?, null when the item is not in the scene.
  • Sources.SourceExistsAsync(name, ct) and Scenes.SceneExistsAsync(name, ct).

Inputs and filters

  • Inputs.SetInputTextAsync(name, text, ct) updates text source content.
  • Inputs.SetInputVolumeDbAsync(name, db, ct) and Inputs.SetInputVolumeMulAsync(name, mul, ct) each pick one unit. The underlying request accepts either and fails when given neither.
  • Inputs.SetInputMutesAsync(inputMutes, ct) sets many mute states in one batch and returns the per-input results.
  • Inputs.CreateInputAsync<T>(kind, name, settings, ...) creates an input with typed settings.
  • Filters.CreateSourceFilterAsync<T>(source, filterName, kind, settings, ct) adds a typed filter.

Media

  • MediaInputs.PlayMediaAsync, PauseMediaAsync, StopMediaAsync and RestartMediaAsync wrap TriggerMediaActionAsync(name, MediaInputAction, ct).

Screenshots

  • Sources.GetSourceScreenshotBytesAsync(source, ...) returns decoded image bytes.
  • Sources.GetSourceScreenshotOnCanvasBytesAsync(source, ...) does the same at canvas dimensions.
  • Sources.SaveSourceScreenshotToFileAsync(source, filePath, ...) writes to disk.

Outputs

  • Record.SetRecordActiveAndWaitAsync(activate, timeout, ct), Stream.SetStreamActiveAndWaitAsync(...) and Outputs.SetVirtualCamActiveAndWaitAsync(...) start or stop the output and wait for confirmation, returning the state the event reported, or null when it does not arrive in time.
  • Record.IsRecordActiveAsync(ct), Stream.IsStreamActiveAsync(ct) and Outputs.IsVirtualCamActiveAsync(ct) read current state.

Application state

  • Config.EnsureProfileActiveAsync(name, ct) and Config.EnsureSceneCollectionActiveAsync(name, ct) switch only if needed, returning whether the target is active.
  • General.TriggerHotkeyAsync(hotkeyName, ct) fires a hotkey by name.

Events

Every event is available as an async sequence on its group. The stream subscribes for the lifetime of the loop and unsubscribes when it ends:

await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellationToken: ct))
{
    Console.WriteLine($"Program scene is now {e.EventData.SceneName}");
}

Streams buffer a bounded number of events and drop the oldest when a consumer falls behind. Pass capacity to change that.

The classic handler is on the same group:

client.Scenes.CurrentProgramSceneChanged += (_, e) =>
    Console.WriteLine($"Program scene is now {e.EventData.SceneName}");

The group's event is the client's event, so both work at once.

Connected, Disconnected, ConnectionFailed and AuthenticationFailure are on the client, since they belong to no protocol category.

To wait for a single occurrence, use WaitForEventAsync. It subscribes before returning, so you can start the wait and then trigger the action:

var changed = await client.WaitForEventAsync<CurrentProgramSceneChangedEventArgs>(ct);

var intro = await client.WaitForEventAsync<CurrentProgramSceneChangedEventArgs>(
    e => e.EventData.SceneName == "Intro",
    TimeSpan.FromSeconds(5),
    ct
);

It throws ObsWebSocketTimeoutException when the wait elapses.

Common tasks

Update a text source

await client.Inputs.SetInputTextAsync("NewsTicker", "Breaking: Live now!", ct);

var settings = new TextGdiPlusInputSettings(Text: "Breaking: Live now!", WordWrap: true);
await client.Inputs.SetInputSettingsAsync("NewsTicker", settings, cancellationToken: ct);

TextGdiPlusInputSettings, TextFreetype2InputSettings, BrowserSourceSettings and the filter settings types are built in, under ObsWebSocket.Core.Protocol.Common.InputSettings and .FilterSettings.

Save the replay buffer

var status = await client.Outputs.GetReplayBufferStatusAsync(ct);
if (status.OutputActive)
{
    await client.Outputs.SaveReplayBufferAsync(ct);
}

Create or update a browser source

var current = await client.Inputs.GetInputSettingsAsync<BrowserSourceSettings>("StreamOverlay", ct);
Console.WriteLine($"Current URL: {current?.Url}");

await client.Inputs.SetInputSettingsAsync(
    "StreamOverlay",
    new BrowserSourceSettings(Url: "https://myoverlay.example.com", Width: 1920, Height: 1080),
    cancellationToken: ct
);

For settings this library does not model, define your own type and pass its JsonTypeInfo:

[JsonSerializable(typeof(OverlaySettings))]
internal partial class MyContext : JsonSerializerContext { }

internal sealed record OverlaySettings(
    [property: JsonPropertyName("url")] string? Url = null,
    [property: JsonPropertyName("css")] string? Css = null
);

await client.Inputs.SetInputSettingsAsync(
    "StreamOverlay",
    new OverlaySettings(Url: "https://myoverlay.example.com"),
    MyContext.Default.OverlaySettings,
    cancellationToken: ct
);

overlay comes before the cancellation token and defaults to true, merging your values onto the existing settings. Pass overlay: false to replace them.

Screenshots

byte[]? png = await client.Sources.GetSourceScreenshotBytesAsync("Intro", "png", cancellationToken: ct);
await client.Sources.SaveSourceScreenshotToFileAsync("Intro", "shot.png", cancellationToken: ct);

Batches

Send several requests in one round trip. Each returns a reference carrying its response type:

ObsBatchBuilder batch = new();
BatchRef<GetVersionResponseData> version = batch.General.GetVersion();
BatchRef<GetSceneListResponseData> scenes = batch.Scenes.GetSceneList(new());
_ = batch.General.Sleep(new(sleepMillis: 100));
_ = batch.Inputs.SetInputMute(new() { InputName = "Mic", InputMuted = false });

BatchResults results = await client.CallBatchAsync(
    batch,
    executionType: RequestBatchExecutionType.SerialRealtime,
    haltOnFailure: false,
    cancellationToken: ct
);

Console.WriteLine(results.Get(version).ObsVersion);
Console.WriteLine(results.Get(scenes).Scenes?.Count);

A request type may appear several times in one batch; each reference resolves to its own result. Sleep is valid only inside a batch.

TryGet reports a failed or missing result instead of throwing. Get throws ObsWebSocketRequestException carrying the OBS status code:

if (!results.AllSucceeded())
{
    foreach (var failed in results.GetFailures())
    {
        Console.WriteLine($"{failed.RequestType}: {failed.RequestStatus.Comment}");
    }
}

With haltOnFailure: true, OBS stops at the first failure, so fewer results come back than requests were sent. Reading a reference past that point throws, and Count reports how many ran.

Add covers anything the generated methods do not, including a raw JsonElement, with an overload taking a JsonTypeInfo<T>:

batch.Add("GetStats");
batch.Add("SetInputSettings", myJsonElement);

Parallel batches

RequestBatchExecutionType.Parallel works, but OBS labels the results incorrectly. It collects them in completion order and labels them from the submission order, so requestType and requestId on a row may not match the requestStatus and responseData beside them. This happens inside OBS and cannot be corrected here. See #16.

Status and payload do come from the same object, so Get and the indexer throw rather than return data under the wrong reference, and TryGet returns false. Results that do not depend on ordering are still exact:

BatchResults results = await client.CallBatchAsync(
    batch, executionType: RequestBatchExecutionType.Parallel, cancellationToken: ct);

bool everythingWorked = results.AllSucceeded();
int failureCount = results.GetFailures().Count();

results.Raw reaches every payload, and GetData<T> reads one without consulting the label, so a batch where every request returns the same type is fully recoverable.

Use Parallel for a set of writes you only need a pass or fail on. When you need results attributed to requests, send them concurrently instead; the client multiplexes on the request id:

Task<GetVersionResponseData> version = client.General.GetVersionAsync(ct);
Task<GetStatsResponseData> stats = client.General.GetStatsAsync(ct);

await Task.WhenAll(version, stats);

That costs a round trip per request. Use a serial batch when the round trip is what you are saving.

Raw requests

Every generated request wraps the same primitives, which stay available for requests this build does not model, a newer OBS, or a vendor plugin:

// Reference type response.
GetVersionResponseData? v = await client.CallAsync<GetVersionResponseData>("GetVersion", null, cancellationToken: ct);

// Value type response, including JsonElement. CallAsync is constrained to classes.
JsonElement? raw = await client.CallAsyncValue<JsonElement>("GetStats", null, cancellationToken: ct);

// Your own request type, with your own context.
[JsonSerializable(typeof(MyRequest))]
internal sealed partial class MyContext : JsonSerializerContext;

JsonElement? answer = await client.CallAsyncValue<JsonElement>(
    "SomeNewRequest", new MyRequest(1), MyContext.Default.MyRequest, cancellationToken: ct);

// Or a JsonElement built by hand.
using JsonDocument body = JsonDocument.Parse("""{"someField":1}""");
JsonElement? viaElement = await client.CallAsyncValue<JsonElement>(
    "SomeNewRequest", body.RootElement, cancellationToken: ct);

// A batch without the typed builder.
List<RequestResponsePayload<object>> results = await client.CallBatchAsync(
    [new BatchRequestItem("GetVersion", null), new BatchRequestItem("GetStats", null)],
    executionType: RequestBatchExecutionType.SerialRealtime,
    cancellationToken: ct);

Request data is written through a source-generated context, so it must be a JsonElement, a type the library knows, or a type you supply metadata for. An anonymous object throws ObsWebSocketSerializationException. JsonSerializer.SerializeToElement without a JsonTypeInfo, and the JsonNode and JsonObject routes, work at runtime but carry IL2026 and IL3050, so they are not options under Native AOT.

Events and enums have the same escape hatch: client.SceneCreated remains alongside client.Scenes.SceneCreated, and ToWireValue() and FromWireValue() convert an enum to and from the protocol string.

Protocol types

The protocol definition has one numeric type and describes enum-valued fields as plain strings. The generated types narrow both.

Numbers. Fields holding whole numbers are generated as int or long, from an explicit list in the generator rather than a rule over field names:

long id = await client.SceneItems.FindSceneItemIdAsync("Intro", "Logo", ct) ?? throw new(...);
await client.SceneItems.SetSceneItemIndexAsync(new(sceneItemId: id, sceneItemIndex: 0, sceneName: "Intro"), ct);

long bytes = (await client.Stream.GetStreamStatusAsync(ct)).OutputBytes;
double volume = (await client.Inputs.GetInputVolumeAsync(new("Mic"), ct)).InputVolumeMul;

Enums. Fields carrying a protocol enum are typed as that enum on both the read and the write side:

client.Outputs.StreamStateChanged += (_, e) =>
{
    string what = e.EventData.OutputState switch
    {
        OutputState.Started => "live",
        OutputState.Starting or OutputState.Reconnecting => "coming up",
        OutputState.Stopped or OutputState.Stopping => "going down",
        OutputState.Unknown => "unrecognised",
        _ => "in between",
    };
};

await client.MediaInputs.TriggerMediaActionAsync("Stinger", MediaInputAction.Restart, ct);

A value this build does not know maps to the enum's zero member rather than throwing.

mediaState, monitorType, sceneItemBlendMode and inputKind have fixed vocabularies but are typed as strings in the protocol and their values are never listed, so they stay strings. The wire values are available as const strings on ObsOutputState and ObsMediaInputAction.

Host integration

builder.AddObsWebSocketClient("obs")   // reads ConnectionStrings:obs
       .WithAutoConnect()              // connects on start, disconnects on stop
       .WithHealthCheck();

The password can travel in the connection string or be set on the options; either way it is kept off ServerUri. A connection that cannot be established at startup is logged rather than thrown, since OBS is often started after the application, and reconnect takes over.

Options are read through IOptionsMonitor, so configuration changes take effect without a restart. 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:

builder.Services.AddObsWebSocketClient(o =>
{
    o.ServerUri = new Uri("ws://localhost:4455");
    o.Password = "secret";
    o.Format = SerializationFormat.MsgPack;
});

Multiple OBS instances

Register clients by name and resolve them with [FromKeyedServices]:

builder.Services.AddObsWebSocketClient("main", o => o.ServerUri = new Uri("ws://localhost:4455"))
       .WithAutoConnect()
       .WithHealthCheck();

builder.Services.AddObsWebSocketClient("booth", o => o.ServerUri = new Uri("ws://booth:4455"))
       .WithAutoConnect();

public sealed class Worker(
    [FromKeyedServices("main")] ObsWebSocketClient main,
    [FromKeyedServices("booth")] ObsWebSocketClient booth);

Each client gets its own options, connection service and health check named after its key.

Errors

try
{
    await client.Ui.SetStudioModeEnabledAsync(new(true), ct);
}
catch (ObsWebSocketRequestException ex)
{
    Console.WriteLine($"{ex.RequestType} failed with {ex.StatusCode}: {ex.Comment}");
}
catch (ObsWebSocketTimeoutException)
{
    // No response within the request timeout.
}

StatusCode is the RequestStatusCode enum, so a filter can name the reason:

using ObsWebSocket.Core.Protocol.Generated;

catch (ObsWebSocketRequestException ex) when (ex.StatusCode is RequestStatusCode.ResourceNotFound)
{
    // The scene, input or filter does not exist.
}

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:

await using ObsWebSocketClient client = new(
    loggerFactory.CreateLogger<ObsWebSocketClient>(),
    format => format is SerializationFormat.MsgPack
        ? new MsgPackMessageSerializer(loggerFactory.CreateLogger<MsgPackMessageSerializer>())
        : new JsonMessageSerializer(loggerFactory.CreateLogger<JsonMessageSerializer>()),
    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 carry jitter so several clients recovering from one outage do not retry in lockstep. Authentication failures are not retried.

Reconnect is not a Polly pipeline. A clean disconnect is not an exception, so the connection loop owns attempt counting and takes only the delay from IObsReconnectDelays. Register your own implementation after AddObsWebSocketClient to replace the curve.

Retrying NotReady

OBS answers NotReady (207) while changing scene collection or shutting down, and documents it as retryable. It rejects the request before the handler runs, so a mutation is as safe to resend as a read. Off by default:

builder.AddObsWebSocketClient("obs", o =>
{
    o.NotReadyRetry.Enabled = true;
    o.NotReadyRetry.MaxRetryAttempts = 5;
});

Each attempt sends a fresh request id, because OBS pairs a response to the id it was sent with. Only 207 is retried. To replace the policy, register a pipeline under ObsWebSocketResilience.NotReadyPipelineKey after adding the client:

builder.AddObsWebSocketClient("obs").WithNotReadyPipeline();

Telemetry

Traces and metrics are published under the name ObsWebSocket.Core, inert until something subscribes:

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource(ObsWebSocketDiagnostics.ActivitySourceName))
    .WithMetrics(m => m.AddMeter(ObsWebSocketDiagnostics.MeterName));

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.

Watch the last two if you rely on events. Streams drop the oldest event when full and the receive loop ignores a message it cannot read, and neither is visible any other way.

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. 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

ObsWebSocket.Example is a host-based sample with configuration and DI: an interactive command loop, listed by help.

The checks that prove the client against a real OBS live in the test project instead. The integration tests call every read request and every safely sendable write request over JSON and MessagePack, and exercise the settings helpers, event streams, WaitForEventAsync, the batch builder, typed enums, screenshots and handles. See CONTRIBUTING.md for how to run them; the Full suite against live OBS workflow runs them 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.

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.

License

MIT. See LICENSE.txt.

About

A modern .NET10 / C# 14 library for interacting with the OBS Studio WebSocket v5 API, featuring source generation for protocol types and DI support. Supports AOT.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

12 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages