A .NET client for the OBS Studio WebSocket v5 protocol, with generated request types and DI-first integration.
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.
dotnet add package ObsWebSocket.CoreThe 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 asGetCanvasList. 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.
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}");
}
}
}| 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.
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.
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.
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 itThe 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'.
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);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 directlyItem(long) and Filter(string) send nothing, since an id and a filter name are the whole
identity.
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.
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)andScenes.SwitchPreviewSceneAsync(scene, ct). OptionaltransitionNameandtransitionDurationMsapply to that switch only.Scenes.SwitchProgramSceneAndWaitAsyncandScenes.SwitchPreviewSceneAndWaitAsyncalso wait for the confirming event.SceneItems.SetSceneItemEnabledAsync(scene, sourceName, isEnabled, ct)returns the resulting state. Pass null forisEnabledto toggle. An overload takes the numeric item id.SceneItems.FindSceneItemIdAsync(scene, sourceName, ct)returnslong?, null when the item is not in the scene.Sources.SourceExistsAsync(name, ct)andScenes.SceneExistsAsync(name, ct).
Inputs and filters
Inputs.SetInputTextAsync(name, text, ct)updates text source content.Inputs.SetInputVolumeDbAsync(name, db, ct)andInputs.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,StopMediaAsyncandRestartMediaAsyncwrapTriggerMediaActionAsync(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(...)andOutputs.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)andOutputs.IsVirtualCamActiveAsync(ct)read current state.
Application state
Config.EnsureProfileActiveAsync(name, ct)andConfig.EnsureSceneCollectionActiveAsync(name, ct)switch only if needed, returning whether the target is active.General.TriggerHotkeyAsync(hotkeyName, ct)fires a hotkey by name.
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.
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.
var status = await client.Outputs.GetReplayBufferStatusAsync(ct);
if (status.OutputActive)
{
await client.Outputs.SaveReplayBufferAsync(ct);
}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.
byte[]? png = await client.Sources.GetSourceScreenshotBytesAsync("Intro", "png", cancellationToken: ct);
await client.Sources.SaveSourceScreenshotToFileAsync("Intro", "shot.png", cancellationToken: ct);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);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.
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.
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.
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;
});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.
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.
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
Formatnever 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.
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 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.
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();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.
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.
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.
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.
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 trueCI 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.
See CONTRIBUTING.md.
MIT. See LICENSE.txt.