From f8cbdb2b42f3daaecc10e64536f446e4cdf6c597 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 19:59:30 +0200 Subject: [PATCH 01/11] feat(core): handles for addressing scenes, inputs, sources and filters --- .../Handles/ObsHandleResolution.cs | 284 ++++++++++++++ ObsWebSocket.Core/Handles/ObsHandles.cs | 365 ++++++++++++++++++ .../ObsWebSocketResourceNotFoundException.cs | 83 ++++ ObsWebSocket.Tests/HandleTests.cs | 172 +++++++++ 4 files changed, 904 insertions(+) create mode 100644 ObsWebSocket.Core/Handles/ObsHandleResolution.cs create mode 100644 ObsWebSocket.Core/Handles/ObsHandles.cs create mode 100644 ObsWebSocket.Core/Handles/ObsWebSocketResourceNotFoundException.cs create mode 100644 ObsWebSocket.Tests/HandleTests.cs diff --git a/ObsWebSocket.Core/Handles/ObsHandleResolution.cs b/ObsWebSocket.Core/Handles/ObsHandleResolution.cs new file mode 100644 index 0000000..6fa3bbc --- /dev/null +++ b/ObsWebSocket.Core/Handles/ObsHandleResolution.cs @@ -0,0 +1,284 @@ +using ObsWebSocket.Core.Protocol.Common; +using ObsWebSocket.Core.Protocol.Requests; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Core; + +// Turning a name into a uuid is a round trip, so it is never done implicitly. +// +// Resolution lives on the category group rather than on the handle, because a handle holds no +// client: it has to be constructible from a bare string for the request overloads to accept one. +// +// There is no narrow lookup in the protocol. Nothing answers "what is the uuid of the scene called +// X", so resolving a scene means GetSceneList and resolving an input means GetInputList. Both are +// cheap by construction: OBS builds each entry from a handful of field reads, and the websocket +// frame costs more than the enumeration. The list also pays for itself, because a miss can say +// what does exist. + +public readonly partial struct ScenesGroup +{ + /// + /// Looks up a scene's uuid, so the handle survives a rename and needs no canvas. + /// + /// The scene to resolve. Already-resolved handles are returned unchanged. + /// A token to cancel the lookup. + /// + /// Thrown when no scene has that name, listing the scenes that do exist. + /// + public async ValueTask ResolveAsync( + SceneHandle scene, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(scene); + if (scene.IsResolved) + { + return scene; + } + + GetSceneListResponseData scenes = await this.GetSceneListAsync( + new GetSceneListRequestData(canvasUuid: scene.Canvas.Uuid), + cancellationToken + ) + .ConfigureAwait(false); + + SceneStub? match = scenes.Scenes.Find(s => + string.Equals(s.SceneName, scene.Name, StringComparison.Ordinal) + ); + + return match is not null + ? SceneHandle.FromUuid(match.SceneUuid) + : throw ObsWebSocketResourceNotFoundException.For( + "scene", + scene.Name!, + scenes.Scenes.ConvertAll(s => s.SceneName), + scene.Canvas + ); + } +} + +public readonly partial struct InputsGroup +{ + /// + /// Looks up an input's uuid, so the handle survives a rename. + /// + /// The input to resolve. Already-resolved handles are returned unchanged. + /// A token to cancel the lookup. + /// + /// Thrown when no input has that name, listing the inputs that do exist. + /// + public async ValueTask ResolveAsync( + InputHandle input, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(input); + if (input.IsResolved) + { + return input; + } + + GetInputListResponseData inputs = await this.GetInputListAsync( + new GetInputListRequestData(), + cancellationToken + ) + .ConfigureAwait(false); + + InputStub? match = inputs.Inputs.Find(i => + string.Equals(i.InputName, input.Name, StringComparison.Ordinal) + ); + + return match is not null + ? InputHandle.FromUuid(match.InputUuid) + : throw ObsWebSocketResourceNotFoundException.For( + "input", + input.Name!, + inputs.Inputs.ConvertAll(i => i.InputName), + null + ); + } +} + +public readonly partial struct CanvasesGroup +{ + /// + /// Looks up a canvas's uuid. + /// + /// + /// The one lookup the protocol cannot express itself: no request takes a canvas name, so this + /// is the only way to address a canvas you know by name. It is what obs-websocket-js was + /// considering adding as a helper for the same reason. + /// + /// The canvas to resolve. The main canvas and resolved handles come back unchanged. + /// A token to cancel the lookup. + /// + /// Thrown when no canvas has that name, listing the canvases that do exist. + /// + public async ValueTask ResolveAsync( + CanvasHandle canvas, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(canvas); + if (canvas.IsResolved || canvas.Name is null) + { + return canvas; + } + + GetCanvasListResponseData canvases = await this.GetCanvasListAsync(cancellationToken) + .ConfigureAwait(false); + + CanvasStub? match = canvases.Canvases.Find(c => + string.Equals(c.CanvasName, canvas.Name, StringComparison.Ordinal) + ); + + return match is not null + ? CanvasHandle.FromUuid(match.CanvasUuid) + : throw ObsWebSocketResourceNotFoundException.For( + "canvas", + canvas.Name, + canvases.Canvases.ConvertAll(c => c.CanvasName), + null + ); + } +} + +public readonly partial struct SourcesGroup +{ + /// + /// Looks up a source's uuid. A source is a scene or an input, so this may take two lookups. + /// + /// + /// Inputs are checked first, because the requests that take a bare source are mostly about + /// inputs, so the scene list is usually never fetched at all. + /// + /// The source to resolve. Already-resolved handles are returned unchanged. + /// A token to cancel the lookup. + /// + /// Thrown when neither the inputs nor the scenes have that name. + /// + public async ValueTask ResolveAsync( + SourceHandle source, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(source); + if (source.IsResolved) + { + return source; + } + + GetInputListResponseData inputs = await client + .Inputs.GetInputListAsync(new GetInputListRequestData(), cancellationToken) + .ConfigureAwait(false); + + InputStub? input = inputs.Inputs.Find(i => + string.Equals(i.InputName, source.Name, StringComparison.Ordinal) + ); + if (input is not null) + { + return SourceHandle.FromUuid(input.InputUuid); + } + + GetSceneListResponseData scenes = await client + .Scenes.GetSceneListAsync( + new GetSceneListRequestData(canvasUuid: source.Canvas.Uuid), + cancellationToken + ) + .ConfigureAwait(false); + + SceneStub? scene = scenes.Scenes.Find(s => + string.Equals(s.SceneName, source.Name, StringComparison.Ordinal) + ); + + return scene is not null + ? SourceHandle.FromUuid(scene.SceneUuid) + : throw ObsWebSocketResourceNotFoundException.For( + "source", + source.Name!, + [ + .. inputs.Inputs.ConvertAll(i => i.InputName), + .. scenes.Scenes.ConvertAll(s => s.SceneName), + ], + source.Canvas + ); + } +} + +public readonly partial struct SceneItemsGroup +{ + /// + /// Looks up the numeric id OBS gave a source inside a scene. + /// + /// + /// The one resolution that is not a convenience. OBS addresses scene items by a number nothing + /// else tells you, so GetSceneItemId is the only way in. + /// + /// The scene and source name to look up. + /// + /// Which match to take when a source appears more than once in the scene. 0 is the first from + /// the bottom; -1 is the topmost. + /// + /// A token to cancel the lookup. + /// + /// Thrown when the scene holds no such source, listing the sources it does hold. + /// + public async ValueTask ResolveAsync( + UnresolvedSceneItem item, + int searchOffset = 0, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(item); + + try + { + GetSceneItemIdResponseData found = await this.GetSceneItemIdAsync( + new GetSceneItemIdRequestData( + sourceName: item.SourceName, + canvasUuid: item.Scene.Canvas.Uuid, + sceneName: item.Scene.Name, + sceneUuid: item.Scene.Uuid, + searchOffset: searchOffset + ), + cancellationToken + ) + .ConfigureAwait(false); + + return SceneItemHandle.For(item.Scene, found.SceneItemId); + } + catch (ObsWebSocketRequestException ex) + when (ex.StatusCode == Protocol.Generated.RequestStatusCode.ResourceNotFound) + { + // OBS says only that it did not find it. The scene's contents are one more request and + // turn that into something the caller can act on. + List present = []; + try + { + GetSceneItemListResponseData items = await this.GetSceneItemListAsync( + new GetSceneItemListRequestData( + canvasUuid: item.Scene.Canvas.Uuid, + sceneName: item.Scene.Name, + sceneUuid: item.Scene.Uuid + ), + cancellationToken + ) + .ConfigureAwait(false); + present = items.SceneItems.ConvertAll(i => i.SourceName); + } + catch (ObsWebSocketRequestException) + { + // Deliberately not logged: the scene may be gone too, and the original failure is + // the one worth reporting. + } + + throw ObsWebSocketResourceNotFoundException.For( + $"source in {item.Scene}", + item.SourceName, + present, + item.Scene.Canvas, + ex + ); + } + } +} diff --git a/ObsWebSocket.Core/Handles/ObsHandles.cs b/ObsWebSocket.Core/Handles/ObsHandles.cs new file mode 100644 index 0000000..45ca96b --- /dev/null +++ b/ObsWebSocket.Core/Handles/ObsHandles.cs @@ -0,0 +1,365 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ObsWebSocket.Core; + +/// +/// How a request addresses one thing in OBS: by name, or by uuid. +/// +/// +/// The protocol takes both as optional fields and resolves them in a fixed order. From +/// Request::AcquireSource: a uuid wins outright, a name is only read when no uuid was sent, +/// the canvas is only consulted on the name path, and neither field present is +/// MissingRequestField. So sending both is not an error, it silently ignores the name, and +/// sending neither compiles today and fails at runtime. +/// +/// A handle is that choice made once and carried, rather than restated on every call. It holds +/// identity only: OBS state drifts, and a handle that cached a name or a scene item id would go +/// stale without saying so. +/// +/// +public interface IObsHandle +{ + /// The name this handle addresses by, or when it holds a uuid. + string? Name { get; } + + /// The uuid this handle addresses by, or when it holds a name. + /// + /// Kept as the wire string and never parsed. OBS produces RFC 4122 uuids, but a response is + /// not the place to discover that one day it did not. + /// + string? Uuid { get; } + + /// + /// Whether this handle addresses by uuid, and so survives a rename. + /// + [MemberNotNullWhen(true, nameof(Uuid))] + bool IsResolved { get; } +} + +/// +/// A canvas. Every canvas-scoped request takes a uuid and there is no canvasName field in +/// the protocol at all, so a name has to be resolved before it can be used. +/// +/// +/// Omitting the canvas means the main canvas, which is why is a value rather +/// than a null check at every call site. +/// +public sealed record CanvasHandle : IObsHandle +{ + private CanvasHandle(string? name, string? uuid) + { + Name = name; + Uuid = uuid; + } + + /// The main canvas, which is what OBS uses when no canvas uuid is sent. + public static CanvasHandle Main { get; } = new(null, null); + + /// + public string? Name { get; } + + /// + public string? Uuid { get; } + + /// + [MemberNotNullWhen(true, nameof(Uuid))] + public bool IsResolved => Uuid is not null; + + /// Addresses a canvas by name, which needs resolving before any request accepts it. + public static CanvasHandle FromName(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + return new CanvasHandle(name, null); + } + + /// Addresses a canvas by uuid, with no lookup. + public static CanvasHandle FromUuid(Guid uuid) => new(null, uuid.ToString("D")); + + /// Addresses a canvas by uuid as OBS wrote it. + public static CanvasHandle FromUuid(string uuid) + { + ArgumentException.ThrowIfNullOrEmpty(uuid); + return new CanvasHandle(null, uuid); + } + + /// Addresses a canvas by name. + public static implicit operator CanvasHandle(string name) => FromName(name); + + /// Addresses a canvas by uuid. + public static implicit operator CanvasHandle(Guid uuid) => FromUuid(uuid); + + /// A scene on this canvas, addressed by name. + public SceneHandle Scene(string name) => SceneHandle.FromName(name, this); + + /// + public override string ToString() => + IsResolved ? $"canvas {Uuid}" + : Name is not null ? $"canvas '{Name}'" + : "the main canvas"; +} + +/// +/// A scene, addressed by name or by uuid. +/// +/// +/// A name is only unique within a canvas, which is why the canvas travels with a name handle and +/// is dropped from a uuid handle: OBS reads canvasUuid only on the name path and ignores it +/// otherwise. +/// +public sealed record SceneHandle : IObsHandle +{ + private SceneHandle(string? name, string? uuid, CanvasHandle canvas) + { + Name = name; + Uuid = uuid; + Canvas = canvas; + } + + /// + public string? Name { get; } + + /// + public string? Uuid { get; } + + /// + /// The canvas a name is looked up in. Meaningless once the handle is resolved, because OBS + /// does not read the canvas field when a uuid is present. + /// + public CanvasHandle Canvas { get; } + + /// + [MemberNotNullWhen(true, nameof(Uuid))] + public bool IsResolved => Uuid is not null; + + /// Addresses a scene by name, optionally on a canvas other than the main one. + public static SceneHandle FromName(string name, CanvasHandle? canvas = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + return new SceneHandle(name, null, canvas ?? CanvasHandle.Main); + } + + /// Addresses a scene by uuid, with no lookup. + public static SceneHandle FromUuid(Guid uuid) => FromUuid(uuid.ToString("D")); + + /// Addresses a scene by uuid as OBS wrote it. + public static SceneHandle FromUuid(string uuid) + { + ArgumentException.ThrowIfNullOrEmpty(uuid); + return new SceneHandle(null, uuid, CanvasHandle.Main); + } + + /// Addresses a scene by name on the main canvas. + public static implicit operator SceneHandle(string name) => FromName(name); + + /// Addresses a scene by uuid. + public static implicit operator SceneHandle(Guid uuid) => FromUuid(uuid); + + /// + /// A scene item in this scene, by the numeric id OBS assigned it. + /// + public SceneItemHandle Item(int sceneItemId) => SceneItemHandle.For(this, sceneItemId); + + /// + /// A scene item in this scene, by the name of the source it shows. Needs resolving, because + /// only GetSceneItemId knows the id. + /// + public UnresolvedSceneItem Item(string sourceName) => + new(this, sourceName ?? throw new ArgumentNullException(nameof(sourceName))); + + /// This scene addressed as a source, for the requests that take any source. + public SourceHandle AsSource() => + IsResolved ? SourceHandle.FromUuid(Uuid) : SourceHandle.FromName(Name!, Canvas); + + /// + public override string ToString() => IsResolved ? $"scene {Uuid}" : $"scene '{Name}'"; +} + +/// +/// An input, addressed by name or by uuid. +/// +/// +/// Input requests carry no canvas field: an input is not scoped to one. +/// +public sealed record InputHandle : IObsHandle +{ + private InputHandle(string? name, string? uuid) + { + Name = name; + Uuid = uuid; + } + + /// + public string? Name { get; } + + /// + public string? Uuid { get; } + + /// + [MemberNotNullWhen(true, nameof(Uuid))] + public bool IsResolved => Uuid is not null; + + /// Addresses an input by name. + public static InputHandle FromName(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + return new InputHandle(name, null); + } + + /// Addresses an input by uuid, with no lookup. + public static InputHandle FromUuid(Guid uuid) => FromUuid(uuid.ToString("D")); + + /// Addresses an input by uuid as OBS wrote it. + public static InputHandle FromUuid(string uuid) + { + ArgumentException.ThrowIfNullOrEmpty(uuid); + return new InputHandle(null, uuid); + } + + /// Addresses an input by name. + public static implicit operator InputHandle(string name) => FromName(name); + + /// Addresses an input by uuid. + public static implicit operator InputHandle(Guid uuid) => FromUuid(uuid); + + /// A filter on this input, by name. Filter names are the identity; nothing to resolve. + public FilterHandle Filter(string filterName) => FilterHandle.For(AsSource(), filterName); + + /// This input addressed as a source, for the requests that take any source. + public SourceHandle AsSource() => + IsResolved ? SourceHandle.FromUuid(Uuid) : SourceHandle.FromName(Name!); + + /// + public override string ToString() => IsResolved ? $"input {Uuid}" : $"input '{Name}'"; +} + +/// +/// A source, which in OBS means either a scene or an input. The requests that take a source accept +/// both, and validate the kind themselves. +/// +public sealed record SourceHandle : IObsHandle +{ + private SourceHandle(string? name, string? uuid, CanvasHandle canvas) + { + Name = name; + Uuid = uuid; + Canvas = canvas; + } + + /// + public string? Name { get; } + + /// + public string? Uuid { get; } + + /// The canvas a name is looked up in. + public CanvasHandle Canvas { get; } + + /// + [MemberNotNullWhen(true, nameof(Uuid))] + public bool IsResolved => Uuid is not null; + + /// Addresses a source by name. + public static SourceHandle FromName(string name, CanvasHandle? canvas = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + return new SourceHandle(name, null, canvas ?? CanvasHandle.Main); + } + + /// Addresses a source by uuid, with no lookup. + public static SourceHandle FromUuid(Guid uuid) => FromUuid(uuid.ToString("D")); + + /// Addresses a source by uuid as OBS wrote it. + public static SourceHandle FromUuid(string uuid) + { + ArgumentException.ThrowIfNullOrEmpty(uuid); + return new SourceHandle(null, uuid, CanvasHandle.Main); + } + + /// Addresses a source by name. + public static implicit operator SourceHandle(string name) => FromName(name); + + /// Addresses a source by uuid. + public static implicit operator SourceHandle(Guid uuid) => FromUuid(uuid); + + /// A filter on this source, by name. + public FilterHandle Filter(string filterName) => FilterHandle.For(this, filterName); + + /// + public override string ToString() => IsResolved ? $"source {Uuid}" : $"source '{Name}'"; +} + +/// +/// A scene item: a scene, and the numeric id OBS gave one source inside it. +/// +/// +/// The id is only meaningful within its scene, and only while the item exists. Removing the item +/// and adding it back gives a new one. +/// +public sealed record SceneItemHandle +{ + private SceneItemHandle(SceneHandle scene, int sceneItemId) + { + Scene = scene; + SceneItemId = sceneItemId; + } + + /// The scene the item lives in. + public SceneHandle Scene { get; } + + /// The numeric id OBS assigned the item. + public int SceneItemId { get; } + + /// Builds a handle for an id already known. + public static SceneItemHandle For(SceneHandle scene, int sceneItemId) + { + ArgumentNullException.ThrowIfNull(scene); + ArgumentOutOfRangeException.ThrowIfNegative(sceneItemId); + return new SceneItemHandle(scene, sceneItemId); + } + + /// + public override string ToString() => $"item {SceneItemId} in {Scene}"; +} + +/// +/// A scene item named by its source rather than its id, which OBS cannot act on until the id is +/// looked up. +/// +/// +/// A separate type rather than a nullable id, so a scene item that has not been resolved cannot be +/// passed to a request that needs one. +/// +public sealed record UnresolvedSceneItem(SceneHandle Scene, string SourceName); + +/// +/// A filter on a source, addressed by name. +/// +/// +/// Filters have no uuid in the protocol, so the name is the identity and there is nothing to +/// resolve. A rename moves the filter out from under the handle. +/// +public sealed record FilterHandle +{ + private FilterHandle(SourceHandle source, string filterName) + { + Source = source; + FilterName = filterName; + } + + /// The source the filter is on. + public SourceHandle Source { get; } + + /// The filter's name, which is its identity. + public string FilterName { get; } + + /// Builds a handle for a filter on a source. + public static FilterHandle For(SourceHandle source, string filterName) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrEmpty(filterName); + return new FilterHandle(source, filterName); + } + + /// + public override string ToString() => $"filter '{FilterName}' on {Source}"; +} diff --git a/ObsWebSocket.Core/Handles/ObsWebSocketResourceNotFoundException.cs b/ObsWebSocket.Core/Handles/ObsWebSocketResourceNotFoundException.cs new file mode 100644 index 0000000..b8f6a9b --- /dev/null +++ b/ObsWebSocket.Core/Handles/ObsWebSocketResourceNotFoundException.cs @@ -0,0 +1,83 @@ +namespace ObsWebSocket.Core; + +/// +/// Thrown when a name does not match anything in OBS. +/// +/// +/// Resolving a name means fetching the list it would have been in, so the list is already in hand +/// when the lookup misses. Saying what does exist costs nothing and turns a typo from a puzzle +/// into an answer, which is more than OBS itself can offer: its own reply is +/// ResourceNotFound and the name you already knew. +/// +public sealed class ObsWebSocketResourceNotFoundException : ObsWebSocketException +{ + /// Initializes a new instance. + public ObsWebSocketResourceNotFoundException() { } + + /// Initializes a new instance with a message. + /// The message. + public ObsWebSocketResourceNotFoundException(string message) + : base(message) { } + + /// Initializes a new instance with a message and an inner exception. + /// The message. + /// The underlying failure. + public ObsWebSocketResourceNotFoundException(string message, Exception innerException) + : base(message, innerException) { } + + private ObsWebSocketResourceNotFoundException( + string message, + string kind, + string requestedName, + IReadOnlyList available, + Exception? innerException + ) + : base(message, innerException!) + { + Kind = kind; + RequestedName = requestedName; + Available = available; + } + + /// What was being looked for, such as scene or input. + public string? Kind { get; } + + /// The name that did not match. + public string? RequestedName { get; } + + /// The names that did exist when the lookup ran. + public IReadOnlyList Available { get; } = []; + + /// Builds the exception, including the available names in the message. + /// What was being looked for. + /// The name that did not match. + /// The names that did exist. + /// The canvas searched, when the search was canvas scoped. + /// The underlying failure, when there was one. + internal static ObsWebSocketResourceNotFoundException For( + string kind, + string requestedName, + IReadOnlyList available, + CanvasHandle? canvas, + Exception? innerException = null + ) + { + string scope = + canvas is null || ReferenceEquals(canvas, CanvasHandle.Main) + ? string.Empty + : $" on {canvas}"; + + string names = + available.Count == 0 + ? "There are none." + : $"Available: {string.Join(", ", available.Select(n => $"'{n}'"))}."; + + return new ObsWebSocketResourceNotFoundException( + $"No {kind} named '{requestedName}'{scope}. {names}", + kind, + requestedName, + available, + innerException + ); + } +} diff --git a/ObsWebSocket.Tests/HandleTests.cs b/ObsWebSocket.Tests/HandleTests.cs new file mode 100644 index 0000000..1c52e2d --- /dev/null +++ b/ObsWebSocket.Tests/HandleTests.cs @@ -0,0 +1,172 @@ +using ObsWebSocket.Core; + +namespace ObsWebSocket.Tests; + +/// +/// A handle encodes the choice OBS makes for you: a uuid wins outright, a name is read only when +/// no uuid was sent, the canvas is consulted only on the name path, and neither is +/// MissingRequestField. The type has to make the last one impossible and the middle two +/// automatic. +/// +[TestClass] +public sealed class HandleTests +{ + private static readonly Guid s_uuid = new("5d5db648-93a5-4985-bff8-45f4c9fe15f7"); + + [TestMethod] + public void AStringIsAName_AndAGuidIsAUuid() + { + SceneHandle byName = "Intro"; + SceneHandle byUuid = s_uuid; + + Assert.AreEqual("Intro", byName.Name); + Assert.IsNull(byName.Uuid); + Assert.IsFalse(byName.IsResolved); + + Assert.IsNull(byUuid.Name); + Assert.AreEqual("5d5db648-93a5-4985-bff8-45f4c9fe15f7", byUuid.Uuid); + Assert.IsTrue(byUuid.IsResolved); + } + + /// + /// OBS writes uuids lowercase and hyphenated, on Windows through UuidCreate and elsewhere + /// through uuid_unparse_lower, which is what Guid's "D" format produces. + /// + [TestMethod] + public void AGuidIsFormattedTheWayObsWritesOne() + { + Assert.AreEqual("5d5db648-93a5-4985-bff8-45f4c9fe15f7", SceneHandle.FromUuid(s_uuid).Uuid); + Assert.AreEqual( + SceneHandle.FromUuid("5d5db648-93a5-4985-bff8-45f4c9fe15f7"), + SceneHandle.FromUuid(s_uuid) + ); + } + + /// + /// A uuid read off the wire is never parsed, so a value OBS sends that is not a Guid cannot + /// break a response. + /// + [TestMethod] + public void AUuidFromTheWireIsCarriedVerbatim() + { + Assert.AreEqual("not-a-guid", SceneHandle.FromUuid("not-a-guid").Uuid); + } + + /// + /// OBS reads canvasUuid only on the name path, so carrying it on a resolved handle would be + /// carrying a field the server ignores. + /// + [TestMethod] + public void ACanvasScopesANameAndIsDroppedByAUuid() + { + CanvasHandle vertical = CanvasHandle.FromUuid(s_uuid); + + Assert.AreEqual(vertical, SceneHandle.FromName("Intro", vertical).Canvas); + Assert.AreEqual(CanvasHandle.Main, SceneHandle.FromUuid(s_uuid).Canvas); + Assert.AreEqual(vertical, vertical.Scene("Intro").Canvas); + } + + [TestMethod] + public void TheMainCanvasCarriesNoUuid_WhichIsWhatOmittingTheFieldMeans() + { + Assert.IsNull(CanvasHandle.Main.Uuid); + Assert.IsFalse(CanvasHandle.Main.IsResolved); + Assert.IsNull(CanvasHandle.Main.Name); + } + + /// + /// A scene is a source in OBS, and the requests that take a bare source accept either. + /// + [TestMethod] + public void ASceneAndAnInputBothNarrowToASource() + { + Assert.AreEqual("Intro", SceneHandle.FromName("Intro").AsSource().Name); + Assert.AreEqual( + "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + SceneHandle.FromUuid(s_uuid).AsSource().Uuid + ); + Assert.AreEqual("Mic", InputHandle.FromName("Mic").AsSource().Name); + Assert.AreEqual( + "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + InputHandle.FromUuid(s_uuid).AsSource().Uuid + ); + } + + /// + /// A scene item known only by its source name cannot be passed where an id is required, so + /// the missing lookup is a compile error rather than a runtime one. + /// + [TestMethod] + public void ASceneItemByIdIsAHandle_ButBySourceNameItIsNotYet() + { + SceneHandle intro = "Intro"; + + SceneItemHandle byId = intro.Item(3); + Assert.AreEqual(3, byId.SceneItemId); + Assert.AreEqual(intro, byId.Scene); + + UnresolvedSceneItem byName = intro.Item("Logo"); + Assert.AreEqual("Logo", byName.SourceName); + Assert.AreEqual(intro, byName.Scene); + } + + /// Filters have no uuid in the protocol, so the name is the identity. + [TestMethod] + public void AFilterIsNamedOnItsSource() + { + FilterHandle eq = InputHandle.FromName("Mic").Filter("EQ"); + + Assert.AreEqual("EQ", eq.FilterName); + Assert.AreEqual("Mic", eq.Source.Name); + } + + [TestMethod] + public void AnEmptyNameIsRefusedRatherThanSentAsOne() + { + _ = Assert.ThrowsExactly(() => SceneHandle.FromName(string.Empty)); + _ = Assert.ThrowsExactly(() => InputHandle.FromName(string.Empty)); + _ = Assert.ThrowsExactly(() => CanvasHandle.FromName(string.Empty)); + _ = Assert.ThrowsExactly(() => + InputHandle.FromName("Mic").Filter(string.Empty) + ); + } + + [TestMethod] + public void ANegativeSceneItemIdIsRefused() + { + _ = Assert.ThrowsExactly(() => + SceneHandle.FromName("Intro").Item(-1) + ); + } + + /// + /// Handles are compared by what they address, so one built from a response equals one written + /// by hand. + /// + [TestMethod] + public void TwoHandlesForTheSameThingAreEqual() + { + Assert.AreEqual(SceneHandle.FromName("Intro"), (SceneHandle)"Intro"); + Assert.AreEqual(SceneHandle.FromUuid(s_uuid), (SceneHandle)s_uuid); + Assert.AreNotEqual(SceneHandle.FromName("Intro"), SceneHandle.FromName("Outro")); + + // A name and a uuid are different addresses even when they point at one scene, because + // nothing here has asked OBS which scene that is. + Assert.AreNotEqual(SceneHandle.FromName("Intro"), SceneHandle.FromUuid(s_uuid)); + } + + [TestMethod] + public void AHandleSaysWhatItAddressesWhenPrinted() + { + Assert.AreEqual("scene 'Intro'", SceneHandle.FromName("Intro").ToString()); + Assert.AreEqual("the main canvas", CanvasHandle.Main.ToString()); + Assert.AreEqual( + "filter 'EQ' on source 'Mic'", + InputHandle.FromName("Mic").Filter("EQ").ToString() + ); + Assert.AreEqual( + "item 3 in scene 'Intro'", + SceneHandle.FromName("Intro").Item(3).ToString() + ); + } +} From fa7f662f27b51917150126e94c0802ecb5471305 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 20:07:56 +0200 Subject: [PATCH 02/11] feat(codegen): generate the requests reachable through a handle 66 of the 147 requests address something by a name-or-uuid pair, and the protocol never says so: it repeats an optional {X}Name beside an optional {X}Uuid. Reading that shape back out gives five operation types, so the set follows a protocol refresh instead of being transcribed. DuplicateSceneItem's second scene falls out of the rule without anyone having thought about it. Not overloads of the existing methods: M(XRequestData) and M(XHandle) are ambiguous the moment a caller writes M(new(...)), and that idiom is all over the surface and the README. --- .../Generation/Emitter.HandleOverloads.cs | 306 ++++ .../Generation/Emitter.Helpers.cs | 48 +- .../Generation/EntityReference.cs | 193 +++ .../Generation/ProtocolCodeGenerator.cs | 1 + .../ObsWebSocketClient.HandleOverloads.g.cs | 1505 +++++++++++++++++ ObsWebSocket.Tests/HandleOperationTests.cs | 194 +++ 6 files changed, 2239 insertions(+), 8 deletions(-) create mode 100644 ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs create mode 100644 ObsWebSocket.Codegen.Tasks/Generation/EntityReference.cs create mode 100644 ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs create mode 100644 ObsWebSocket.Tests/HandleOperationTests.cs diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs new file mode 100644 index 0000000..b6a7dfe --- /dev/null +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs @@ -0,0 +1,306 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ObsWebSocket.Codegen.Tasks.Generation; + +internal static partial class Emitter +{ + /// + /// Emits, for each kind of thing OBS can address, a type carrying every request about it, with + /// the name, uuid and canvas fields already supplied by the handle. + /// + /// + /// These are deliberately not overloads of the existing request methods. A method pair of + /// M(XRequestData) and M(XHandle) is ambiguous the moment a caller writes + /// M(new(...)), because a target typed new is convertible to either, and that + /// idiom is all over the existing surface and the README. Giving the handle form its own type + /// keeps both usable. + /// + /// The set is derived from the field shape rather than listed, so a request added upstream + /// that takes a scene appears here the day the definition lands. + /// + /// + public static void GenerateHandleOverloads( + SourceProductionContext context, + ProtocolDefinition protocol + ) + { + if (protocol.Requests is null) + { + return; + } + + // Each request belongs to the thing it is primarily about, which is its first reference. + Dictionary< + string, + List<(RequestDefinition Request, IReadOnlyList Refs)> + > byKind = new(StringComparer.Ordinal); + + foreach (RequestDefinition request in protocol.Requests) + { + IReadOnlyList references = EntityReferenceTable.Find( + request.RequestFields + ); + if (references.Count == 0) + { + continue; + } + + string kind = references[0].Kind; + if (!byKind.TryGetValue(kind, out var list)) + { + list = []; + byKind[kind] = list; + } + + list.Add((request, references)); + } + + StringBuilder builder = new(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("using System;"); + builder.AppendLine("using System.Threading;"); + builder.AppendLine("using System.Threading.Tasks;"); + builder.AppendLine("using ObsWebSocket.Core.Protocol.Requests;"); + builder.AppendLine("using ObsWebSocket.Core.Protocol.Responses;"); + builder.AppendLine($"using {GeneratedCommonNamespace};"); + builder.AppendLine(); + builder.AppendLine($"namespace {ExtensionsNamespace};"); + builder.AppendLine(); + + int emitted = 0; + foreach (string kind in byKind.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + string opsType = OpsTypeName(kind); + string handleType = HandleTypeForKind(kind); + + builder.AppendLine("/// "); + builder.AppendLine($"/// Every request about one {DescribeKind(kind)}."); + builder.AppendLine("/// "); + builder.AppendLine( + "/// The client these requests are sent on." + ); + builder.AppendLine( + $"/// The {DescribeKind(kind)} they are about." + ); + builder.AppendLine( + $"public readonly partial struct {opsType}(ObsWebSocketClient client, {handleType} handle)" + ); + builder.AppendLine("{"); + builder.AppendLine( + $" /// The {DescribeKind(kind)} these requests address." + ); + builder.AppendLine($" public {handleType} Handle => handle;"); + builder.AppendLine(); + + foreach ( + (RequestDefinition request, IReadOnlyList refs) in byKind[kind] + .OrderBy(r => r.Request.RequestType, StringComparer.Ordinal) + ) + { + EmitScopedRequest(context, builder, request, refs); + builder.AppendLine(); + emitted++; + } + + builder.AppendLine("}"); + builder.AppendLine(); + } + + builder.AppendLine("/// "); + builder.AppendLine( + "/// Addresses one thing in OBS, so the requests about it need not restate which." + ); + builder.AppendLine("/// "); + builder.AppendLine("public static class ObsWebSocketHandleExtensions"); + builder.AppendLine("{"); + foreach (string kind in byKind.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + builder.AppendLine(" extension(ObsWebSocketClient client)"); + builder.AppendLine(" {"); + builder.AppendLine( + $" /// Every request about one {DescribeKind(kind)}." + ); + builder.AppendLine( + $" /// The {DescribeKind(kind)}, which a name or a uuid converts to." + ); + builder.AppendLine( + $" public {OpsTypeName(kind)} {AccessorName(kind)}({HandleTypeForKind(kind)} handle) => new(client, handle);" + ); + builder.AppendLine(" }"); + builder.AppendLine(); + } + + builder.AppendLine("}"); + builder.AppendLine(); + builder.AppendLine($"// Requests reachable through a handle: {emitted}"); + + context.AddSource( + "ObsWebSocketClient.HandleOverloads.g.cs", + SourceText.From(builder.ToString(), Encoding.UTF8) + ); + } + + private static string OpsTypeName(string kind) => + kind switch + { + "sceneItem" => "SceneItemOperations", + "filter" => "FilterOperations", + _ => ToPascalCase(kind) + "Operations", + }; + + private static string HandleTypeForKind(string kind) => + kind switch + { + "sceneItem" => "SceneItemHandle", + "filter" => "FilterHandle", + _ => EntityReferenceTable.HandleTypeFor(kind)!, + }; + + private static string AccessorName(string kind) => + kind switch + { + "sceneItem" => "SceneItem", + "filter" => "Filter", + _ => ToPascalCase(kind), + }; + + private static string DescribeKind(string kind) => + kind switch + { + "sceneItem" => "scene item", + _ => kind, + }; + + private static void EmitScopedRequest( + SourceProductionContext context, + StringBuilder builder, + RequestDefinition request, + IReadOnlyList references + ) + { + string baseName = SanitizeIdentifier(request.RequestType); + string methodName = baseName + "Async"; + string requestDto = $"{GeneratedRequestsNamespace}.{baseName}RequestData"; + bool hasResponse = request.ResponseFields?.Count > 0; + string returnType = hasResponse + ? $"Task<{GeneratedResponsesNamespace}.{baseName}ResponseData>" + : "Task"; + string groupName = ToGroupName(request.Category ?? "general"); + + HashSet consumed = new(StringComparer.Ordinal); + foreach (EntityReference reference in references) + { + foreach (string field in reference.Fields) + { + _ = consumed.Add(field); + } + } + + List> arguments = + [ + .. EntityReferenceTable.ArgumentsFor(references[0], "handle"), + ]; + + List required = []; + List optional = []; + List docs = []; + + // A second reference, such as the destination of a duplicated scene item, stays a + // parameter: only one thing can be the subject. + foreach (EntityReference extra in references.Skip(1)) + { + string paramName = EntityReferenceTable.ParameterNameForReference(extra); + required.Add($"{EntityReferenceTable.HandleTypeForReference(extra)} {paramName}"); + docs.Add( + $" /// The {DescribeKind(extra.Kind)} to use." + ); + arguments.AddRange(EntityReferenceTable.ArgumentsFor(extra, paramName)); + } + + foreach (FieldDefinition field in request.RequestFields!) + { + if (consumed.Contains(field.ValueName)) + { + continue; + } + + (string? csharpType, _) = MapProtocolTypeToCSharp( + context, + field, + $"{baseName}RequestData", + reportDiagnostics: false + ); + if (csharpType is null) + { + continue; + } + + string paramName = ToCamelCase(SanitizeIdentifier(ToPascalCase(field.ValueName))); + arguments.Add(new(field.ValueName, paramName)); + docs.Add( + $" /// {System.Security.SecurityElement.Escape(FirstLine(field.ValueDescription))}" + ); + + if (field.ValueOptional == true) + { + string suffix = csharpType.EndsWith("?", StringComparison.Ordinal) ? "" : "?"; + optional.Add($"{csharpType}{suffix} {paramName} = null"); + } + else + { + required.Add($"{csharpType} {paramName}"); + } + } + + builder.AppendLine(" /// "); + AppendMultiLineXmlDoc(builder, request.Description, " ///"); + builder.AppendLine(" /// "); + foreach (string doc in docs) + { + builder.AppendLine(doc); + } + + builder.AppendLine( + " /// A token to cancel the asynchronous operation." + ); + builder.AppendLine( + hasResponse + ? " /// A task yielding the response data." + : " /// A task that completes when OBS has processed the request." + ); + + List parameters = + [ + .. required, + .. optional, + "CancellationToken cancellationToken = default", + ]; + + builder.AppendLine($" public {returnType} {methodName}("); + builder.AppendLine(" " + string.Join(",\n ", parameters)); + builder.AppendLine(" ) =>"); + builder.AppendLine($" client.{groupName}.{methodName}("); + builder.AppendLine($" new {requestDto}("); + builder.AppendLine( + " " + + string.Join( + ",\n ", + arguments.Select(a => + $"{ToCamelCase(SanitizeIdentifier(ToPascalCase(a.Key)))}: {a.Value}" + ) + ) + ); + builder.AppendLine(" ),"); + builder.AppendLine(" cancellationToken"); + builder.AppendLine(" );"); + } + + private static string FirstLine(string? text) => + string.IsNullOrWhiteSpace(text) + ? "The value to send." + : text!.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)[0].Trim(); +} diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs index 24ee3c4..636e3e0 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs @@ -219,10 +219,15 @@ private static StringBuilder BuildSourceHeader(string? fileTypeComment = null) [System.Text.RegularExpressions.GeneratedRegex(@"\d\.\d")] private static partial System.Text.RegularExpressions.Regex FractionalRestriction(); + /// + /// False when a second emitter is asking about a field the DTO emitter has already reported + /// on, so the same finding is not raised twice for one field. + /// private static (string? CSharpType, bool IsValueType) MapProtocolTypeToCSharp( SourceProductionContext context, FieldDefinition field, - string parentDtoName + string parentDtoName, + bool reportDiagnostics = true ) { string obsType = field.ValueType; @@ -266,7 +271,9 @@ string parentDtoName && FractionalRestriction().IsMatch(restrictions) ) { - context.ReportDiagnostic( + ReportIf( + reportDiagnostics, + context, Diagnostic.Create( Diagnostics.FractionalFieldClassifiedAsWhole, Location.None, @@ -278,7 +285,9 @@ string parentDtoName if (!classified) { - context.ReportDiagnostic( + ReportIf( + reportDiagnostics, + context, Diagnostic.Create( Diagnostics.UnclassifiedNumberField, Location.None, @@ -310,7 +319,9 @@ string parentDtoName // Only warn if it fell back to JsonElement and wasn't handled by specific rules above if (mappedType == "System.Text.Json.JsonElement?") { - context.ReportDiagnostic( + ReportIf( + reportDiagnostics, + context, Diagnostic.Create( Diagnostics.NestedObjectNotSupportedWarning, Location.None, @@ -325,7 +336,9 @@ string parentDtoName if (isOptional && IsValueType(mappedType) && !mappedType.EndsWith("?")) { // Report diagnostic for optional value types that need nullable annotation - context.ReportDiagnostic( + ReportIf( + reportDiagnostics, + context, Diagnostic.Create( Diagnostics.OptionalValueTypeWarning, Location.None, @@ -378,7 +391,9 @@ string parentDtoName } else // Fallback for an array whose item type is not mapped to a stub. { - context.ReportDiagnostic( + ReportIf( + reportDiagnostics, + context, Diagnostic.Create( Diagnostics.ArrayItemTypeUnknownWarning, Location.None, @@ -417,7 +432,9 @@ string parentDtoName } else // Inner primitive type could not be mapped { - context.ReportDiagnostic( + ReportIf( + reportDiagnostics, + context, Diagnostic.Create( Diagnostics.ArrayItemTypeUnknownWarning, Location.None, @@ -433,7 +450,9 @@ string parentDtoName // --- Handle Unmappable Type --- // If we reach here, the obsType is not a basic type, not Object/Any, and not Array - context.ReportDiagnostic( + ReportIf( + reportDiagnostics, + context, Diagnostic.Create( Diagnostics.UnmappableTypeError, Location.None, @@ -846,4 +865,17 @@ EnumDefinition enumDef return (overallKind, null); } } + + /// Reports a diagnostic unless a second pass is asking about the same field. + private static void ReportIf( + bool report, + SourceProductionContext context, + Diagnostic diagnostic + ) + { + if (report) + { + context.ReportDiagnostic(diagnostic); + } + } } diff --git a/ObsWebSocket.Codegen.Tasks/Generation/EntityReference.cs b/ObsWebSocket.Codegen.Tasks/Generation/EntityReference.cs new file mode 100644 index 0000000..187c05d --- /dev/null +++ b/ObsWebSocket.Codegen.Tasks/Generation/EntityReference.cs @@ -0,0 +1,193 @@ +namespace ObsWebSocket.Codegen.Tasks.Generation; + +/// +/// One thing a request addresses, and the protocol fields that address it. +/// +/// The entity kind, such as scene or input. +/// +/// Which reference this is within the request. Usually the same as the kind, but +/// DuplicateSceneItem names a second scene destinationScene. +/// +/// The protocol fields this reference consumes. +internal sealed record EntityReference(string Kind, string Role, IReadOnlyList Fields); + +/// +/// Finds the things a request addresses, from the shape of its fields rather than a list of +/// request names. +/// +/// +/// The protocol never says "this request takes a scene". It says the request has an optional +/// sceneName and an optional sceneUuid, which is the same statement made twice per +/// request across 68 of them. Reading that shape back out is what lets the handle overloads be +/// generated instead of transcribed, and it is why DuplicateSceneItem's second scene falls +/// out without anyone thinking about it. +/// +internal static class EntityReferenceTable +{ + /// + /// The kinds that are addressed by a name-or-uuid pair, mapped to the handle type that carries + /// them. A kind not listed here is a field that happens to end in Name, not an entity. + /// + private static readonly Dictionary s_handleTypes = new(StringComparer.Ordinal) + { + ["scene"] = "SceneHandle", + ["destinationScene"] = "SceneHandle", + ["input"] = "InputHandle", + ["source"] = "SourceHandle", + }; + + /// Returns the handle type for a kind, or if it is not one. + public static string? HandleTypeFor(string kind) => + s_handleTypes.TryGetValue(kind, out string? type) ? type : null; + + /// + /// Reads the entity references out of a request's fields. + /// + /// + /// A {X}Name and {X}Uuid pair, both optional, is a reference to X. Both optional + /// matters: CreateInput takes a required inputName for the input it is about to + /// make, which is a value, not a reference to something that already exists. + /// + /// canvasUuid is folded into the scene or source reference it scopes, because OBS reads + /// it only when resolving a name and ignores it beside a uuid. A request with a canvas and no + /// name-addressable entity keeps it as an ordinary parameter. + /// + /// + /// A sceneItemId beside a scene reference, and a filterName beside a source + /// reference, are composite references: both fields together address one thing. + /// + /// + public static IReadOnlyList Find(IReadOnlyList? fields) + { + if (fields is null || fields.Count == 0) + { + return []; + } + + Dictionary byName = new(StringComparer.Ordinal); + foreach (FieldDefinition f in fields) + { + byName[f.ValueName] = f; + } + + List found = []; + foreach (FieldDefinition field in fields) + { + string name = field.ValueName; + if (!name.EndsWith("Name", StringComparison.Ordinal) || field.ValueOptional != true) + { + continue; + } + + string role = name.Substring(0, name.Length - "Name".Length); + string uuid = role + "Uuid"; + if ( + !byName.TryGetValue(uuid, out FieldDefinition? uuidField) + || uuidField.ValueOptional != true + ) + { + continue; + } + + string kind = role; + if (HandleTypeFor(kind) is null) + { + continue; + } + + found.Add(new EntityReference(kind, role, [name, uuid])); + } + + if (found.Count == 0) + { + return found; + } + + // The canvas scopes the first name-addressed reference in the request. Only one reference + // can own it, and in every request that has both it is the primary scene or source. + if (byName.ContainsKey("canvasUuid")) + { + EntityReference primary = found[0]; + found[0] = primary with { Fields = [.. primary.Fields, "canvasUuid"] }; + } + + // Composite references: the id or the filter name plus the entity it hangs off. + EntityReference? scene = found.Find(r => r.Kind == "scene"); + if (scene is not null && byName.ContainsKey("sceneItemId")) + { + found[found.IndexOf(scene)] = new EntityReference( + "sceneItem", + "sceneItem", + [.. scene.Fields, "sceneItemId"] + ); + } + + EntityReference? source = found.Find(r => r.Kind == "source"); + if (source is not null && byName.ContainsKey("filterName")) + { + found[found.IndexOf(source)] = new EntityReference( + "filter", + "filter", + [.. source.Fields, "filterName"] + ); + } + + return found; + } + + /// The handle type that carries a reference, including the composite kinds. + public static string HandleTypeForReference(EntityReference reference) => + reference.Kind switch + { + "sceneItem" => "SceneItemHandle", + "filter" => "FilterHandle", + _ => HandleTypeFor(reference.Kind)!, + }; + + /// The parameter name a reference is given in a generated overload. + public static string ParameterNameForReference(EntityReference reference) => + reference.Role switch + { + "destinationScene" => "destinationScene", + "sceneItem" => "sceneItem", + "filter" => "filter", + _ => reference.Kind, + }; + + /// + /// The arguments a reference contributes to the generated request record, keyed by protocol + /// field name. + /// + /// + /// A handle holds either a name or a uuid, never both, so writing both fields sends exactly + /// one of them and the other stays null. That is the shape OBS resolves, without the caller + /// having to know the order it resolves in. + /// + public static IEnumerable> ArgumentsFor( + EntityReference reference, + string parameterName + ) + { + string root = reference.Kind switch + { + "sceneItem" => $"{parameterName}.Scene", + "filter" => $"{parameterName}.Source", + _ => parameterName, + }; + + foreach (string field in reference.Fields) + { + yield return field switch + { + "canvasUuid" => new("canvasUuid", $"{root}.Canvas.Uuid"), + "sceneItemId" => new("sceneItemId", $"{parameterName}.SceneItemId"), + "filterName" => new("filterName", $"{parameterName}.FilterName"), + _ when field.EndsWith("Uuid", StringComparison.Ordinal) => new( + field, + $"{root}.Uuid" + ), + _ => new(field, $"{root}.Name"), + }; + } + } +} diff --git a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs index 3693ac3..e32fb08 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs @@ -43,6 +43,7 @@ IReadOnlyList Diagnostics Emitter.GenerateResponseDtos(context, protocol); Emitter.GeneratePayloadSchema(context, protocol); Emitter.GenerateClientExtensions(context, protocol); + Emitter.GenerateHandleOverloads(context, protocol); Emitter.GenerateEventPayloads(context, protocol); Emitter.GenerateEventArgs(context, protocol); Emitter.GenerateClientEventInfrastructure(context, protocol); diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs new file mode 100644 index 0000000..23b29b5 --- /dev/null +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs @@ -0,0 +1,1505 @@ +// +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using ObsWebSocket.Core.Protocol.Requests; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Core.Protocol.Common; + +namespace ObsWebSocket.Core; + +/// +/// Every request about one filter. +/// +/// The client these requests are sent on. +/// The filter they are about. +public readonly partial struct FilterOperations(ObsWebSocketClient client, FilterHandle handle) +{ + /// The filter these requests address. + public FilterHandle Handle => handle; + + /// + /// Creates a new filter, adding it to the specified source. + /// + /// The kind of filter to be created + /// Settings object to initialize the filter with + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task CreateSourceFilterAsync( + string filterKind, + System.Text.Json.JsonElement? filterSettings = null, + CancellationToken cancellationToken = default + ) => + client.Filters.CreateSourceFilterAsync( + new ObsWebSocket.Core.Protocol.Requests.CreateSourceFilterRequestData( + sourceName: handle.Source.Name, + sourceUuid: handle.Source.Uuid, + canvasUuid: handle.Source.Canvas.Uuid, + filterName: handle.FilterName, + filterKind: filterKind, + filterSettings: filterSettings + ), + cancellationToken + ); + + /// + /// Gets the info for a specific source filter. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSourceFilterAsync( + CancellationToken cancellationToken = default + ) => + client.Filters.GetSourceFilterAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSourceFilterRequestData( + sourceName: handle.Source.Name, + sourceUuid: handle.Source.Uuid, + canvasUuid: handle.Source.Canvas.Uuid, + filterName: handle.FilterName + ), + cancellationToken + ); + + /// + /// Removes a filter from a source. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task RemoveSourceFilterAsync( + CancellationToken cancellationToken = default + ) => + client.Filters.RemoveSourceFilterAsync( + new ObsWebSocket.Core.Protocol.Requests.RemoveSourceFilterRequestData( + sourceName: handle.Source.Name, + sourceUuid: handle.Source.Uuid, + canvasUuid: handle.Source.Canvas.Uuid, + filterName: handle.FilterName + ), + cancellationToken + ); + + /// + /// Sets the enable state of a source filter. + /// + /// New enable state of the filter + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSourceFilterEnabledAsync( + bool filterEnabled, + CancellationToken cancellationToken = default + ) => + client.Filters.SetSourceFilterEnabledAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSourceFilterEnabledRequestData( + sourceName: handle.Source.Name, + sourceUuid: handle.Source.Uuid, + canvasUuid: handle.Source.Canvas.Uuid, + filterName: handle.FilterName, + filterEnabled: filterEnabled + ), + cancellationToken + ); + + /// + /// Sets the index position of a filter on a source. + /// + /// New index position of the filter + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSourceFilterIndexAsync( + int filterIndex, + CancellationToken cancellationToken = default + ) => + client.Filters.SetSourceFilterIndexAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSourceFilterIndexRequestData( + sourceName: handle.Source.Name, + sourceUuid: handle.Source.Uuid, + canvasUuid: handle.Source.Canvas.Uuid, + filterName: handle.FilterName, + filterIndex: filterIndex + ), + cancellationToken + ); + + /// + /// Sets the name of a source filter (rename). + /// + /// New name for the filter + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSourceFilterNameAsync( + string newFilterName, + CancellationToken cancellationToken = default + ) => + client.Filters.SetSourceFilterNameAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSourceFilterNameRequestData( + sourceName: handle.Source.Name, + sourceUuid: handle.Source.Uuid, + canvasUuid: handle.Source.Canvas.Uuid, + filterName: handle.FilterName, + newFilterName: newFilterName + ), + cancellationToken + ); + + /// + /// Sets the settings of a source filter. + /// + /// Object of settings to apply + /// True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings. + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSourceFilterSettingsAsync( + System.Text.Json.JsonElement? filterSettings, + bool? overlay = null, + CancellationToken cancellationToken = default + ) => + client.Filters.SetSourceFilterSettingsAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSourceFilterSettingsRequestData( + sourceName: handle.Source.Name, + sourceUuid: handle.Source.Uuid, + canvasUuid: handle.Source.Canvas.Uuid, + filterName: handle.FilterName, + filterSettings: filterSettings, + overlay: overlay + ), + cancellationToken + ); + +} + +/// +/// Every request about one input. +/// +/// The client these requests are sent on. +/// The input they are about. +public readonly partial struct InputOperations(ObsWebSocketClient client, InputHandle handle) +{ + /// The input these requests address. + public InputHandle Handle => handle; + + /// + /// Gets the audio balance of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputAudioBalanceAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputAudioBalanceAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputAudioBalanceRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the audio monitor type of an input. + /// + /// The available audio monitor types are: + /// + /// - `OBS_MONITORING_TYPE_NONE` + /// - `OBS_MONITORING_TYPE_MONITOR_ONLY` + /// - `OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT` + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputAudioMonitorTypeAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputAudioMonitorTypeAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputAudioMonitorTypeRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the audio sync offset of an input. + /// + /// Note: The audio sync offset can be negative too! + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputAudioSyncOffsetAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputAudioSyncOffsetAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputAudioSyncOffsetRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the enable state of all audio tracks of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputAudioTracksAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputAudioTracksAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputAudioTracksRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the deinterlace field order of an input. + /// + /// Deinterlace Field Orders: + /// + /// - `OBS_DEINTERLACE_FIELD_ORDER_TOP` + /// - `OBS_DEINTERLACE_FIELD_ORDER_BOTTOM` + /// + /// Note: Deinterlacing functionality is restricted to async inputs only. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputDeinterlaceFieldOrderAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputDeinterlaceFieldOrderAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputDeinterlaceFieldOrderRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the deinterlace mode of an input. + /// + /// Deinterlace Modes: + /// + /// - `OBS_DEINTERLACE_MODE_DISABLE` + /// - `OBS_DEINTERLACE_MODE_DISCARD` + /// - `OBS_DEINTERLACE_MODE_RETRO` + /// - `OBS_DEINTERLACE_MODE_BLEND` + /// - `OBS_DEINTERLACE_MODE_BLEND_2X` + /// - `OBS_DEINTERLACE_MODE_LINEAR` + /// - `OBS_DEINTERLACE_MODE_LINEAR_2X` + /// - `OBS_DEINTERLACE_MODE_YADIF` + /// - `OBS_DEINTERLACE_MODE_YADIF_2X` + /// + /// Note: Deinterlacing functionality is restricted to async inputs only. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputDeinterlaceModeAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputDeinterlaceModeAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputDeinterlaceModeRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the audio mute state of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputMuteAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputMuteAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputMuteRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the items of a list property from an input's properties. + /// + /// Note: 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. + /// + /// Name of the list property to get the items of + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputPropertiesListPropertyItemsAsync( + string propertyName, + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputPropertiesListPropertyItemsAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputPropertiesListPropertyItemsRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + propertyName: propertyName + ), + cancellationToken + ); + + /// + /// Gets the settings of an input. + /// + /// Note: Does not include defaults. To create the entire settings object, overlay `inputSettings` over the `defaultInputSettings` provided by `GetInputDefaultSettings`. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputSettingsAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputSettingsAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputSettingsRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the current volume setting of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetInputVolumeAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.GetInputVolumeAsync( + new ObsWebSocket.Core.Protocol.Requests.GetInputVolumeRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Gets the status of a media input. + /// + /// Media States: + /// + /// - `OBS_MEDIA_STATE_NONE` + /// - `OBS_MEDIA_STATE_PLAYING` + /// - `OBS_MEDIA_STATE_OPENING` + /// - `OBS_MEDIA_STATE_BUFFERING` + /// - `OBS_MEDIA_STATE_PAUSED` + /// - `OBS_MEDIA_STATE_STOPPED` + /// - `OBS_MEDIA_STATE_ENDED` + /// - `OBS_MEDIA_STATE_ERROR` + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetMediaInputStatusAsync( + CancellationToken cancellationToken = default + ) => + client.MediaInputs.GetMediaInputStatusAsync( + new ObsWebSocket.Core.Protocol.Requests.GetMediaInputStatusRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Offsets the current cursor position of a media input by the specified value. + /// + /// This request does not perform bounds checking of the cursor position. + /// + /// Value to offset the current cursor position by + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task OffsetMediaInputCursorAsync( + long mediaCursorOffset, + CancellationToken cancellationToken = default + ) => + client.MediaInputs.OffsetMediaInputCursorAsync( + new ObsWebSocket.Core.Protocol.Requests.OffsetMediaInputCursorRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + mediaCursorOffset: mediaCursorOffset + ), + cancellationToken + ); + + /// + /// Opens the filters dialog of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task OpenInputFiltersDialogAsync( + CancellationToken cancellationToken = default + ) => + client.Ui.OpenInputFiltersDialogAsync( + new ObsWebSocket.Core.Protocol.Requests.OpenInputFiltersDialogRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Opens the interact dialog of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task OpenInputInteractDialogAsync( + CancellationToken cancellationToken = default + ) => + client.Ui.OpenInputInteractDialogAsync( + new ObsWebSocket.Core.Protocol.Requests.OpenInputInteractDialogRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Opens the properties dialog of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task OpenInputPropertiesDialogAsync( + CancellationToken cancellationToken = default + ) => + client.Ui.OpenInputPropertiesDialogAsync( + new ObsWebSocket.Core.Protocol.Requests.OpenInputPropertiesDialogRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Presses a button in the properties of an input. + /// + /// Some known `propertyName` values are: + /// + /// - `refreshnocache` - Browser source reload button + /// + /// Note: 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. + /// + /// Name of the button property to press + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task PressInputPropertiesButtonAsync( + string propertyName, + CancellationToken cancellationToken = default + ) => + client.Inputs.PressInputPropertiesButtonAsync( + new ObsWebSocket.Core.Protocol.Requests.PressInputPropertiesButtonRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + propertyName: propertyName + ), + cancellationToken + ); + + /// + /// Removes an existing input. + /// + /// Note: Will immediately remove all associated scene items. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task RemoveInputAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.RemoveInputAsync( + new ObsWebSocket.Core.Protocol.Requests.RemoveInputRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Sets the audio balance of an input. + /// + /// New audio balance value + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputAudioBalanceAsync( + double inputAudioBalance, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputAudioBalanceAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputAudioBalanceRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputAudioBalance: inputAudioBalance + ), + cancellationToken + ); + + /// + /// Sets the audio monitor type of an input. + /// + /// Audio monitor type + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputAudioMonitorTypeAsync( + string monitorType, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputAudioMonitorTypeAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputAudioMonitorTypeRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + monitorType: monitorType + ), + cancellationToken + ); + + /// + /// Sets the audio sync offset of an input. + /// + /// New audio sync offset in milliseconds + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputAudioSyncOffsetAsync( + int inputAudioSyncOffset, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputAudioSyncOffsetAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputAudioSyncOffsetRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputAudioSyncOffset: inputAudioSyncOffset + ), + cancellationToken + ); + + /// + /// Sets the enable state of audio tracks of an input. + /// + /// Track settings to apply + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputAudioTracksAsync( + System.Collections.Generic.Dictionary? inputAudioTracks, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputAudioTracksAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputAudioTracksRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputAudioTracks: inputAudioTracks + ), + cancellationToken + ); + + /// + /// Sets the deinterlace field order of an input. + /// + /// Note: Deinterlacing functionality is restricted to async inputs only. + /// + /// Deinterlace field order for the input + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputDeinterlaceFieldOrderAsync( + string inputDeinterlaceFieldOrder, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputDeinterlaceFieldOrderAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputDeinterlaceFieldOrderRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputDeinterlaceFieldOrder: inputDeinterlaceFieldOrder + ), + cancellationToken + ); + + /// + /// Sets the deinterlace mode of an input. + /// + /// Note: Deinterlacing functionality is restricted to async inputs only. + /// + /// Deinterlace mode for the input + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputDeinterlaceModeAsync( + string inputDeinterlaceMode, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputDeinterlaceModeAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputDeinterlaceModeRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputDeinterlaceMode: inputDeinterlaceMode + ), + cancellationToken + ); + + /// + /// Sets the audio mute state of an input. + /// + /// Whether to mute the input or not + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputMuteAsync( + bool inputMuted, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputMuteAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputMuteRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputMuted: inputMuted + ), + cancellationToken + ); + + /// + /// Sets the name of an input (rename). + /// + /// New name for the input + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputNameAsync( + string newInputName, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputNameAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputNameRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + newInputName: newInputName + ), + cancellationToken + ); + + /// + /// Sets the settings of an input. + /// + /// Object of settings to apply + /// True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings. + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputSettingsAsync( + System.Text.Json.JsonElement? inputSettings, + bool? overlay = null, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputSettingsAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputSettingsRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputSettings: inputSettings, + overlay: overlay + ), + cancellationToken + ); + + /// + /// Sets the volume setting of an input. + /// + /// Volume setting in mul + /// Volume setting in dB + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetInputVolumeAsync( + double? inputVolumeMul = null, + double? inputVolumeDb = null, + CancellationToken cancellationToken = default + ) => + client.Inputs.SetInputVolumeAsync( + new ObsWebSocket.Core.Protocol.Requests.SetInputVolumeRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + inputVolumeMul: inputVolumeMul, + inputVolumeDb: inputVolumeDb + ), + cancellationToken + ); + + /// + /// Sets the cursor position of a media input. + /// + /// This request does not perform bounds checking of the cursor position. + /// + /// New cursor position to set + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetMediaInputCursorAsync( + long mediaCursor, + CancellationToken cancellationToken = default + ) => + client.MediaInputs.SetMediaInputCursorAsync( + new ObsWebSocket.Core.Protocol.Requests.SetMediaInputCursorRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + mediaCursor: mediaCursor + ), + cancellationToken + ); + + /// + /// Toggles the audio mute state of an input. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task ToggleInputMuteAsync( + CancellationToken cancellationToken = default + ) => + client.Inputs.ToggleInputMuteAsync( + new ObsWebSocket.Core.Protocol.Requests.ToggleInputMuteRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Triggers an action on a media input. + /// + /// Identifier of the `ObsMediaInputAction` enum + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task TriggerMediaInputActionAsync( + ObsWebSocket.Core.Protocol.Generated.MediaInputAction mediaAction, + CancellationToken cancellationToken = default + ) => + client.MediaInputs.TriggerMediaInputActionAsync( + new ObsWebSocket.Core.Protocol.Requests.TriggerMediaInputActionRequestData( + inputName: handle.Name, + inputUuid: handle.Uuid, + mediaAction: mediaAction + ), + cancellationToken + ); + +} + +/// +/// Every request about one scene. +/// +/// The client these requests are sent on. +/// The scene they are about. +public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneHandle handle) +{ + /// The scene these requests address. + public SceneHandle Handle => handle; + + /// + /// Creates a new input, adding it as a scene item to the specified scene. + /// + /// Name of the new input to created + /// The kind of input to be created + /// Settings object to initialize the input with + /// Whether to set the created scene item to enabled or disabled + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task CreateInputAsync( + string inputName, + string inputKind, + System.Text.Json.JsonElement? inputSettings = null, + bool? sceneItemEnabled = null, + CancellationToken cancellationToken = default + ) => + client.Inputs.CreateInputAsync( + new ObsWebSocket.Core.Protocol.Requests.CreateInputRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + inputName: inputName, + inputKind: inputKind, + inputSettings: inputSettings, + sceneItemEnabled: sceneItemEnabled + ), + cancellationToken + ); + + /// + /// Creates a new scene item using a source. + /// + /// Scenes only + /// + /// The source to use. + /// Enable state to apply to the scene item on creation + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task CreateSceneItemAsync( + SourceHandle source, + bool? sceneItemEnabled = null, + CancellationToken cancellationToken = default + ) => + client.SceneItems.CreateSceneItemAsync( + new ObsWebSocket.Core.Protocol.Requests.CreateSceneItemRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + sourceName: source.Name, + sourceUuid: source.Uuid, + sceneItemEnabled: sceneItemEnabled + ), + cancellationToken + ); + + /// + /// Basically GetSceneItemList, but for groups. + /// + /// Using groups at all in OBS is discouraged, as they are very broken under the hood. Please use nested scenes instead. + /// + /// Groups only + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetGroupSceneItemListAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetGroupSceneItemListAsync( + new ObsWebSocket.Core.Protocol.Requests.GetGroupSceneItemListRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid + ), + cancellationToken + ); + + /// + /// Searches a scene for a source, and returns its id. + /// + /// Scenes and Groups + /// + /// Name of the source to find + /// Number of matches to skip during search. >= 0 means first forward. -1 means last (top) item + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemIdAsync( + string sourceName, + int? searchOffset = null, + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemIdAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemIdRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + sourceName: sourceName, + searchOffset: searchOffset + ), + cancellationToken + ); + + /// + /// Gets a list of all scene items in a scene. + /// + /// Scenes only + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemListAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemListAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemListRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid + ), + cancellationToken + ); + + /// + /// Gets the scene transition overridden for a scene. + /// + /// Note: A transition UUID response field is not currently able to be implemented as of 2024-1-18. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneSceneTransitionOverrideAsync( + CancellationToken cancellationToken = default + ) => + client.Scenes.GetSceneSceneTransitionOverrideAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneSceneTransitionOverrideRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid + ), + cancellationToken + ); + + /// + /// Removes a scene from OBS. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task RemoveSceneAsync( + CancellationToken cancellationToken = default + ) => + client.Scenes.RemoveSceneAsync( + new ObsWebSocket.Core.Protocol.Requests.RemoveSceneRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid + ), + cancellationToken + ); + + /// + /// Sets the current preview scene. + /// + /// Only available when studio mode is enabled. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetCurrentPreviewSceneAsync( + CancellationToken cancellationToken = default + ) => + client.Scenes.SetCurrentPreviewSceneAsync( + new ObsWebSocket.Core.Protocol.Requests.SetCurrentPreviewSceneRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Sets the current program scene. + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetCurrentProgramSceneAsync( + CancellationToken cancellationToken = default + ) => + client.Scenes.SetCurrentProgramSceneAsync( + new ObsWebSocket.Core.Protocol.Requests.SetCurrentProgramSceneRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// Sets the name of a scene (rename). + /// + /// New name for the scene + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSceneNameAsync( + string newSceneName, + CancellationToken cancellationToken = default + ) => + client.Scenes.SetSceneNameAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSceneNameRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + newSceneName: newSceneName + ), + cancellationToken + ); + + /// + /// Sets the scene transition overridden for a scene. + /// + /// Name of the scene transition to use as override. Specify `null` to remove + /// Duration to use for any overridden transition. Specify `null` to remove + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSceneSceneTransitionOverrideAsync( + string? transitionName = null, + int? transitionDuration = null, + CancellationToken cancellationToken = default + ) => + client.Scenes.SetSceneSceneTransitionOverrideAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSceneSceneTransitionOverrideRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + transitionName: transitionName, + transitionDuration: transitionDuration + ), + cancellationToken + ); + +} + +/// +/// Every request about one scene item. +/// +/// The client these requests are sent on. +/// The scene item they are about. +public readonly partial struct SceneItemOperations(ObsWebSocketClient client, SceneItemHandle handle) +{ + /// The scene item these requests address. + public SceneItemHandle Handle => handle; + + /// + /// Duplicates a scene item, copying all transform and crop info. + /// + /// Scenes only + /// + /// The destinationScene to use. + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task DuplicateSceneItemAsync( + SceneHandle destinationScene, + CancellationToken cancellationToken = default + ) => + client.SceneItems.DuplicateSceneItemAsync( + new ObsWebSocket.Core.Protocol.Requests.DuplicateSceneItemRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId, + destinationSceneName: destinationScene.Name, + destinationSceneUuid: destinationScene.Uuid + ), + cancellationToken + ); + + /// + /// Gets the blend mode of a scene item. + /// + /// Blend modes: + /// + /// - `OBS_BLEND_NORMAL` + /// - `OBS_BLEND_ADDITIVE` + /// - `OBS_BLEND_SUBTRACT` + /// - `OBS_BLEND_SCREEN` + /// - `OBS_BLEND_MULTIPLY` + /// - `OBS_BLEND_LIGHTEN` + /// - `OBS_BLEND_DARKEN` + /// + /// Scenes and Groups + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemBlendModeAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemBlendModeAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemBlendModeRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId + ), + cancellationToken + ); + + /// + /// Gets the enable state of a scene item. + /// + /// Scenes and Groups + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemEnabledAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemEnabledAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemEnabledRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId + ), + cancellationToken + ); + + /// + /// Gets the index position of a scene item in a scene. + /// + /// An index of 0 is at the bottom of the source list in the UI. + /// + /// Scenes and Groups + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemIndexAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemIndexAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemIndexRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId + ), + cancellationToken + ); + + /// + /// Gets the lock state of a scene item. + /// + /// Scenes and Groups + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemLockedAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemLockedAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemLockedRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId + ), + cancellationToken + ); + + /// + /// Gets the source associated with a scene item. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemSourceAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemSourceAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemSourceRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId + ), + cancellationToken + ); + + /// + /// Gets the transform and crop info of a scene item. + /// + /// Scenes and Groups + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSceneItemTransformAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.GetSceneItemTransformAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSceneItemTransformRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId + ), + cancellationToken + ); + + /// + /// Removes a scene item from a scene. + /// + /// Scenes only + /// + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task RemoveSceneItemAsync( + CancellationToken cancellationToken = default + ) => + client.SceneItems.RemoveSceneItemAsync( + new ObsWebSocket.Core.Protocol.Requests.RemoveSceneItemRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId + ), + cancellationToken + ); + + /// + /// Sets the blend mode of a scene item. + /// + /// Scenes and Groups + /// + /// New blend mode + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSceneItemBlendModeAsync( + string sceneItemBlendMode, + CancellationToken cancellationToken = default + ) => + client.SceneItems.SetSceneItemBlendModeAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSceneItemBlendModeRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId, + sceneItemBlendMode: sceneItemBlendMode + ), + cancellationToken + ); + + /// + /// Sets the enable state of a scene item. + /// + /// Scenes and Groups + /// + /// New enable state of the scene item + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSceneItemEnabledAsync( + bool sceneItemEnabled, + CancellationToken cancellationToken = default + ) => + client.SceneItems.SetSceneItemEnabledAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSceneItemEnabledRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId, + sceneItemEnabled: sceneItemEnabled + ), + cancellationToken + ); + + /// + /// Sets the index position of a scene item in a scene. + /// + /// Scenes and Groups + /// + /// New index position of the scene item + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSceneItemIndexAsync( + int sceneItemIndex, + CancellationToken cancellationToken = default + ) => + client.SceneItems.SetSceneItemIndexAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSceneItemIndexRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId, + sceneItemIndex: sceneItemIndex + ), + cancellationToken + ); + + /// + /// Sets the lock state of a scene item. + /// + /// Scenes and Group + /// + /// New lock state of the scene item + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSceneItemLockedAsync( + bool sceneItemLocked, + CancellationToken cancellationToken = default + ) => + client.SceneItems.SetSceneItemLockedAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSceneItemLockedRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId, + sceneItemLocked: sceneItemLocked + ), + cancellationToken + ); + + /// + /// Sets the transform and crop info of a scene item. + /// + /// Object containing scene item transform info to update + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SetSceneItemTransformAsync( + ObsWebSocket.Core.Protocol.Common.SceneItemTransformPatchStub? sceneItemTransform, + CancellationToken cancellationToken = default + ) => + client.SceneItems.SetSceneItemTransformAsync( + new ObsWebSocket.Core.Protocol.Requests.SetSceneItemTransformRequestData( + sceneName: handle.Scene.Name, + sceneUuid: handle.Scene.Uuid, + canvasUuid: handle.Scene.Canvas.Uuid, + sceneItemId: handle.SceneItemId, + sceneItemTransform: sceneItemTransform + ), + cancellationToken + ); + +} + +/// +/// Every request about one source. +/// +/// The client these requests are sent on. +/// The source they are about. +public readonly partial struct SourceOperations(ObsWebSocketClient client, SourceHandle handle) +{ + /// The source these requests address. + public SourceHandle Handle => handle; + + /// + /// Gets the active and show state of a source. + /// + /// **Compatible with inputs and scenes.** + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSourceActiveAsync( + CancellationToken cancellationToken = default + ) => + client.Sources.GetSourceActiveAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSourceActiveRequestData( + sourceName: handle.Name, + sourceUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid + ), + cancellationToken + ); + + /// + /// Gets an array of all of a source's filters. + /// + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSourceFilterListAsync( + CancellationToken cancellationToken = default + ) => + client.Filters.GetSourceFilterListAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSourceFilterListRequestData( + sourceName: handle.Name, + sourceUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid + ), + cancellationToken + ); + + /// + /// Gets a Base64-encoded screenshot of a source. + /// + /// The `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. + /// If `imageWidth` and `imageHeight` are not specified, the compressed image will use the full resolution of the source. + /// + /// **Compatible with inputs and scenes.** + /// + /// Image compression format to use. Use `GetVersion` to get compatible image formats + /// Width to scale the screenshot to + /// Height to scale the screenshot to + /// Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use "default" (whatever that means, idk) + /// A token to cancel the asynchronous operation. + /// A task yielding the response data. + public Task GetSourceScreenshotAsync( + string imageFormat, + int? imageWidth = null, + int? imageHeight = null, + int? imageCompressionQuality = null, + CancellationToken cancellationToken = default + ) => + client.Sources.GetSourceScreenshotAsync( + new ObsWebSocket.Core.Protocol.Requests.GetSourceScreenshotRequestData( + sourceName: handle.Name, + sourceUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + imageFormat: imageFormat, + imageWidth: imageWidth, + imageHeight: imageHeight, + imageCompressionQuality: imageCompressionQuality + ), + cancellationToken + ); + + /// + /// Opens a projector for a source. + /// + /// Note: This request serves to provide feature parity with 4.x. It is very likely to be changed/deprecated in a future release. + /// + /// Monitor index, use `GetMonitorList` to obtain index + /// Size/Position data for a windowed projector, in Qt Base64 encoded format. Mutually exclusive with `monitorIndex` + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task OpenSourceProjectorAsync( + int? monitorIndex = null, + string? projectorGeometry = null, + CancellationToken cancellationToken = default + ) => + client.Ui.OpenSourceProjectorAsync( + new ObsWebSocket.Core.Protocol.Requests.OpenSourceProjectorRequestData( + sourceName: handle.Name, + sourceUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + monitorIndex: monitorIndex, + projectorGeometry: projectorGeometry + ), + cancellationToken + ); + + /// + /// Saves a screenshot of a source to the filesystem. + /// + /// The `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. + /// If `imageWidth` and `imageHeight` are not specified, the compressed image will use the full resolution of the source. + /// + /// **Compatible with inputs and scenes.** + /// + /// Image compression format to use. Use `GetVersion` to get compatible image formats + /// Path to save the screenshot file to. Eg. `C:\Users\user\Desktop\screenshot.png` + /// Width to scale the screenshot to + /// Height to scale the screenshot to + /// Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use "default" (whatever that means, idk) + /// A token to cancel the asynchronous operation. + /// A task that completes when OBS has processed the request. + public Task SaveSourceScreenshotAsync( + string imageFormat, + string imageFilePath, + int? imageWidth = null, + int? imageHeight = null, + int? imageCompressionQuality = null, + CancellationToken cancellationToken = default + ) => + client.Sources.SaveSourceScreenshotAsync( + new ObsWebSocket.Core.Protocol.Requests.SaveSourceScreenshotRequestData( + sourceName: handle.Name, + sourceUuid: handle.Uuid, + canvasUuid: handle.Canvas.Uuid, + imageFormat: imageFormat, + imageFilePath: imageFilePath, + imageWidth: imageWidth, + imageHeight: imageHeight, + imageCompressionQuality: imageCompressionQuality + ), + cancellationToken + ); + +} + +/// +/// Addresses one thing in OBS, so the requests about it need not restate which. +/// +public static class ObsWebSocketHandleExtensions +{ + extension(ObsWebSocketClient client) + { + /// Every request about one filter. + /// The filter, which a name or a uuid converts to. + public FilterOperations Filter(FilterHandle handle) => new(client, handle); + } + + extension(ObsWebSocketClient client) + { + /// Every request about one input. + /// The input, which a name or a uuid converts to. + public InputOperations Input(InputHandle handle) => new(client, handle); + } + + extension(ObsWebSocketClient client) + { + /// Every request about one scene. + /// The scene, which a name or a uuid converts to. + public SceneOperations Scene(SceneHandle handle) => new(client, handle); + } + + extension(ObsWebSocketClient client) + { + /// Every request about one scene item. + /// The scene item, which a name or a uuid converts to. + public SceneItemOperations SceneItem(SceneItemHandle handle) => new(client, handle); + } + + extension(ObsWebSocketClient client) + { + /// Every request about one source. + /// The source, which a name or a uuid converts to. + public SourceOperations Source(SourceHandle handle) => new(client, handle); + } + +} + +// Requests reachable through a handle: 66 diff --git a/ObsWebSocket.Tests/HandleOperationTests.cs b/ObsWebSocket.Tests/HandleOperationTests.cs new file mode 100644 index 0000000..9b5f3d3 --- /dev/null +++ b/ObsWebSocket.Tests/HandleOperationTests.cs @@ -0,0 +1,194 @@ +using System.Net.WebSockets; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Moq; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Networking; +using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// The generated operations exist to send the right identity field and no other. OBS resolves a +/// uuid before a name and reads the canvas only on the name path, so a handle that sent both would +/// be sending a field the server ignores, and one that sent neither would be +/// MissingRequestField. +/// +[TestClass] +public sealed class HandleOperationTests +{ + private static readonly Guid s_uuid = new("5d5db648-93a5-4985-bff8-45f4c9fe15f7"); + + [TestMethod] + public async Task ANameHandleSendsTheNameAndNoUuid() + { + JsonElement sent = await CaptureAsync( + (client, ct) => client.Scene("Intro").SetCurrentProgramSceneAsync(ct) + ); + + Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); + Assert.IsFalse( + sent.TryGetProperty("sceneUuid", out JsonElement uuid) + && uuid.ValueKind is not JsonValueKind.Null, + "a name handle must not also send a uuid" + ); + } + + [TestMethod] + public async Task AUuidHandleSendsTheUuidAndNoName() + { + JsonElement sent = await CaptureAsync( + (client, ct) => client.Scene(s_uuid).SetCurrentProgramSceneAsync(ct) + ); + + Assert.AreEqual( + "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + sent.GetProperty("sceneUuid").GetString() + ); + Assert.IsFalse( + sent.TryGetProperty("sceneName", out JsonElement name) + && name.ValueKind is not JsonValueKind.Null, + "a uuid handle must not also send a name" + ); + } + + /// + /// The canvas travels with a name, because that is the only path OBS reads it on. + /// + [TestMethod] + public async Task ACanvasScopedNameSendsTheCanvas() + { + CanvasHandle vertical = CanvasHandle.FromUuid(s_uuid); + + JsonElement sent = await CaptureAsync( + (client, ct) => client.Scene(vertical.Scene("Intro")).GetSceneItemListAsync(ct) + ); + + Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); + Assert.AreEqual( + "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + sent.GetProperty("canvasUuid").GetString() + ); + } + + /// + /// A composite handle supplies both halves of the identity: the scene, and the id inside it. + /// + [TestMethod] + public async Task ASceneItemSendsItsSceneAndItsId() + { + JsonElement sent = await CaptureAsync( + (client, ct) => + client + .SceneItem(SceneHandle.FromName("Intro").Item(7)) + .SetSceneItemEnabledAsync(false, ct) + ); + + Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); + Assert.AreEqual(7, sent.GetProperty("sceneItemId").GetInt32()); + Assert.IsFalse(sent.GetProperty("sceneItemEnabled").GetBoolean()); + } + + [TestMethod] + public async Task AFilterSendsItsSourceAndItsName() + { + JsonElement sent = await CaptureAsync( + (client, ct) => + client + .Filter(InputHandle.FromName("Mic").Filter("EQ")) + .SetSourceFilterEnabledAsync(true, ct) + ); + + Assert.AreEqual("Mic", sent.GetProperty("sourceName").GetString()); + Assert.AreEqual("EQ", sent.GetProperty("filterName").GetString()); + Assert.IsTrue(sent.GetProperty("filterEnabled").GetBoolean()); + } + + /// + /// A second reference in one request stays a parameter, because only one thing can be the + /// subject. DuplicateSceneItem is the only request in the protocol shaped this way. + /// + [TestMethod] + public async Task ASecondReferenceIsAParameter() + { + JsonElement sent = await CaptureAsync( + (client, ct) => + client + .SceneItem(SceneHandle.FromName("Intro").Item(7)) + .DuplicateSceneItemAsync(destinationScene: "Outro", cancellationToken: ct) + ); + + Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); + Assert.AreEqual(7, sent.GetProperty("sceneItemId").GetInt32()); + Assert.AreEqual("Outro", sent.GetProperty("destinationSceneName").GetString()); + } + + /// A bare string is a name, which is the whole point of the implicit conversion. + [TestMethod] + public async Task AStringReachesTheOperationsWithoutCeremony() + { + JsonElement sent = await CaptureAsync( + (client, ct) => client.Input("Mic").ToggleInputMuteAsync(ct) + ); + + Assert.AreEqual("Mic", sent.GetProperty("inputName").GetString()); + } + + /// + /// Captures the requestData of the request the action sends. + /// + /// + /// The call is cancelled the moment the bytes are on the wire, which is everything this test + /// class is about. Letting it run to completion would mean canning a response per request + /// shape, and the response is not what is under test. + /// + private static async Task CaptureAsync( + Func act + ) + { + (ObsWebSocketClient? client, _, Mock? socket) = + TestUtils.SetupConnectedClientForceState(); + + JsonElement? captured = null; + using CancellationTokenSource stopOnceSent = new(); + + _ = socket + .Setup(ws => + ws.SendAsync( + It.IsAny>(), + It.IsAny(), + true, + It.IsAny() + ) + ) + .Callback( + ( + ReadOnlyMemory buffer, + WebSocketMessageType type, + bool end, + CancellationToken token + ) => + { + OutgoingMessage? message = JsonSerializer.Deserialize< + OutgoingMessage + >(buffer.Span, TestUtils.s_jsonSerializerOptions); + captured = message?.D?.RequestData; + stopOnceSent.Cancel(); + } + ) + .Returns(ValueTask.CompletedTask); + + try + { + await act(client, stopOnceSent.Token); + } + catch (OperationCanceledException) + { + // Expected: the request was cancelled as soon as it had been sent. + } + + Assert.IsNotNull(captured, "no request was sent"); + return captured.Value; + } +} From e897b238a20828d702ab59766e1d673984aefd76 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 20:09:51 +0200 Subject: [PATCH 03/11] test(example): verify handles against a live OBS on both transports --- ObsWebSocket.Example/Worker.cs | 155 +++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 18fce6c..a2be61b 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -3013,6 +3013,141 @@ await client .ConfigureAwait(false) ); + results.Add( + await TrySettingsCheckAsync( + "Handles address by name and by uuid", + async () => + { + // The point of a uuid handle is that it survives a rename. Both forms + // have to reach the same scene for that to be worth anything. + SceneHandle byName = sceneName; + SceneHandle byUuid = await client + .Scenes.ResolveAsync(byName, cancellationToken) + .ConfigureAwait(false); + + GetSceneItemListResponseData viaName = await client + .Scene(byName) + .GetSceneItemListAsync(cancellationToken) + .ConfigureAwait(false); + GetSceneItemListResponseData viaUuid = await client + .Scene(byUuid) + .GetSceneItemListAsync(cancellationToken) + .ConfigureAwait(false); + + string renamed = sceneName + "_renamed"; + await client + .Scene(byUuid) + .SetSceneNameAsync(renamed, cancellationToken) + .ConfigureAwait(false); + + bool uuidStillWorks; + try + { + _ = await client + .Scene(byUuid) + .GetSceneItemListAsync(cancellationToken) + .ConfigureAwait(false); + uuidStillWorks = true; + } + catch (ObsWebSocketRequestException) + { + uuidStillWorks = false; + } + + bool nameNowMisses; + try + { + _ = await client + .Scene(byName) + .GetSceneItemListAsync(cancellationToken) + .ConfigureAwait(false); + nameNowMisses = false; + } + catch (ObsWebSocketRequestException) + { + nameNowMisses = true; + } + + await client + .Scene(byUuid) + .SetSceneNameAsync(sceneName, CancellationToken.None) + .ConfigureAwait(false); + + return ( + byUuid.IsResolved + && viaName.SceneItems.Count == viaUuid.SceneItems.Count + && uuidStillWorks + && nameNowMisses, + $"resolved to {byUuid.Uuid}, both read {viaUuid.SceneItems.Count} " + + $"item(s); after a rename the uuid still resolves " + + $"({uuidStillWorks}) and the name does not ({nameNowMisses})" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "A miss says what does exist", + async () => + { + // OBS answers ResourceNotFound and the name you already gave it. The + // list is in hand from the lookup, so the client can do better. + ObsWebSocketResourceNotFoundException ex = + await ExpectThrowAsync(() => + client + .Scenes.ResolveAsync( + "__obsws_no_such_scene", + cancellationToken + ) + .AsTask() + ) + .ConfigureAwait(false); + + return ( + ex.Available.Count > 0 + && ex.Message.Contains( + "__obsws_no_such_scene", + StringComparison.Ordinal + ) + && ex.Available.Contains(sceneName, StringComparer.Ordinal), + $"named {ex.Available.Count} scene(s), including the one the run made" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "A scene item resolves by source name", + async () => + { + // The one lookup that is not a convenience: OBS addresses scene items + // by a number nothing else reports. + SceneItemHandle item = await client + .SceneItems.ResolveAsync( + SceneHandle.FromName(sceneName).Item(inputName), + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + GetSceneItemEnabledResponseData enabled = await client + .SceneItem(item) + .GetSceneItemEnabledAsync(cancellationToken) + .ConfigureAwait(false); + + return ( + item.SceneItemId >= 0, + $"'{inputName}' is item {item.SceneItemId}, enabled " + + $"{enabled.SceneItemEnabled}" + ); + } + ) + .ConfigureAwait(false) + ); + results.Add( await TrySettingsCheckAsync( "Canvases category", @@ -5664,6 +5799,26 @@ await Probe( ]; } + /// + /// Runs an action expected to throw, and hands back the exception it threw. + /// + private static async Task ExpectThrowAsync(Func action) + where TException : Exception + { + try + { + await action().ConfigureAwait(false); + } + catch (TException expected) + { + return expected; + } + + throw new InvalidOperationException( + $"Expected {typeof(TException).Name}, but the call succeeded." + ); + } + private static void RenderKeyValueTable( string title, IReadOnlyList<(string Key, string Value)> rows From fa39c2f5191c30176c08298548b345fa963d7d36 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 20:24:09 +0200 Subject: [PATCH 04/11] feat(codegen): handles from the events and responses that already carry a uuid --- .../Generation/Emitter.PayloadHandles.cs | 246 ++++++++++++++ .../Generation/ProtocolCodeGenerator.cs | 1 + .../ObsWebSocketClient.PayloadHandles.g.cs | 303 ++++++++++++++++++ ObsWebSocket.Tests/HandleTests.cs | 67 ++++ 4 files changed, 617 insertions(+) create mode 100644 ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs create mode 100644 ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs new file mode 100644 index 0000000..198d968 --- /dev/null +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs @@ -0,0 +1,246 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ObsWebSocket.Codegen.Tasks.Generation; + +internal static partial class Emitter +{ + /// + /// Emits handle accessors on every event payload and response that already carries a uuid. + /// + /// + /// These are the handles that cost nothing. An event announcing a scene change already says + /// which scene, by uuid, so acting on it needs no lookup and the result is immune to a rename + /// that happens between the event arriving and the next request going out. Without them the + /// caller reads e.EventData.SceneName and addresses the scene by name again, which is + /// the round trip and the race the uuid was there to avoid. + /// + public static void GeneratePayloadHandles( + SourceProductionContext context, + ProtocolDefinition protocol + ) + { + StringBuilder builder = new(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("using System;"); + builder.AppendLine($"using {GeneratedCommonNamespace};"); + builder.AppendLine(); + builder.AppendLine($"namespace {ExtensionsNamespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + "/// Handles for the things an event or a response already identifies by uuid." + ); + builder.AppendLine("/// "); + builder.AppendLine("public static class ObsWebSocketPayloadHandles"); + builder.AppendLine("{"); + + int emitted = 0; + + if (protocol.Events is not null) + { + foreach ( + OBSEvent definition in protocol.Events.OrderBy( + e => e.EventType, + StringComparer.Ordinal + ) + ) + { + emitted += EmitAccessors( + builder, + $"{GeneratedEventsNamespace}.{SanitizeIdentifier(definition.EventType)}Payload", + "payload", + definition.DataFields + ); + } + } + + if (protocol.Requests is not null) + { + foreach ( + RequestDefinition request in protocol.Requests.OrderBy( + r => r.RequestType, + StringComparer.Ordinal + ) + ) + { + if (request.ResponseFields is null || request.ResponseFields.Count == 0) + { + continue; + } + + emitted += EmitAccessors( + builder, + $"{GeneratedResponsesNamespace}.{SanitizeIdentifier(request.RequestType)}ResponseData", + "response", + request.ResponseFields + ); + } + } + + builder.AppendLine("}"); + builder.AppendLine(); + builder.AppendLine($"// Handles reachable without a lookup: {emitted}"); + + context.AddSource( + "ObsWebSocketClient.PayloadHandles.g.cs", + SourceText.From(builder.ToString(), Encoding.UTF8) + ); + } + + /// + /// Emits one extension block for a payload, holding an accessor per uuid it carries. + /// + private static int EmitAccessors( + StringBuilder builder, + string payloadType, + string parameterName, + IReadOnlyList? fields + ) + { + if (fields is null || fields.Count == 0) + { + return 0; + } + + Dictionary byName = new(StringComparer.Ordinal); + foreach (FieldDefinition field in fields) + { + byName[field.ValueName] = field; + } + + List<( + string Accessor, + string HandleType, + string Expression, + bool Nullable, + string Doc + )> accessors = []; + + foreach (FieldDefinition field in fields) + { + string name = field.ValueName; + if (!name.EndsWith("Uuid", StringComparison.Ordinal)) + { + continue; + } + + string role = name.Substring(0, name.Length - "Uuid".Length); + string? handleType = HandleTypeForRole(role); + if (handleType is null) + { + continue; + } + + string property = SanitizeIdentifier(ToPascalCase(name)); + string accessor = SanitizeIdentifier(ToPascalCase(role)); + bool nullable = + field.ValueOptional == true || DescriptionAllowsNull(field.ValueDescription); + + accessors.Add( + ( + accessor, + handleType, + $"{handleType}.FromUuid({parameterName}.{property})", + nullable, + $"The {DescribeRole(role)} this message identifies, addressed by uuid so a rename cannot move it." + ) + ); + } + + // A scene item needs the scene as well as the id, and both travel together when they + // travel at all. + if (byName.ContainsKey("sceneItemId") && byName.ContainsKey("sceneUuid")) + { + FieldDefinition idField = byName["sceneItemId"]; + bool nullable = + idField.ValueOptional == true || DescriptionAllowsNull(idField.ValueDescription); + accessors.Add( + ( + "SceneItem", + "SceneItemHandle", + $"SceneItemHandle.For(SceneHandle.FromUuid({parameterName}.SceneUuid), {parameterName}.SceneItemId)", + nullable, + "The scene item this message is about, ready to act on without a lookup." + ) + ); + } + + if (accessors.Count == 0) + { + return 0; + } + + builder.AppendLine($" extension({payloadType} {parameterName})"); + builder.AppendLine(" {"); + foreach ( + ( + string accessor, + string handleType, + string expression, + bool nullable, + string doc + ) in accessors + ) + { + builder.AppendLine($" /// {doc}"); + if (nullable) + { + string source = expression[(expression.IndexOf('(') + 1)..^1]; + builder.AppendLine($" public {handleType}? {accessor} =>"); + builder.AppendLine( + $" string.IsNullOrEmpty({FirstArgument(source)}) ? null : {expression};" + ); + } + else + { + builder.AppendLine($" public {handleType} {accessor} => {expression};"); + } + + builder.AppendLine(); + } + + builder.AppendLine(" }"); + builder.AppendLine(); + return accessors.Count; + } + + /// The uuid expression a null check has to look at. + private static string FirstArgument(string arguments) + { + int comma = arguments.IndexOf(','); + return comma < 0 ? arguments : arguments[..comma].Trim(); + } + + /// + /// The handle type a {role}Uuid field yields, or null when the role is not something a + /// request can address. + /// + /// + /// transitionUuid appears on four events and one response, but no request accepts one, + /// so a handle for it could not be used for anything. It is left out until the protocol grows + /// somewhere to send it. + /// + private static string? HandleTypeForRole(string role) => + role switch + { + "scene" or "currentProgramScene" or "currentPreviewScene" or "destinationScene" => + "SceneHandle", + "input" => "InputHandle", + "source" => "SourceHandle", + "canvas" => "CanvasHandle", + _ => null, + }; + + private static string DescribeRole(string role) => + role switch + { + "currentProgramScene" => "program scene", + "currentPreviewScene" => "preview scene", + "destinationScene" => "destination scene", + _ => role, + }; +} diff --git a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs index e32fb08..4b636c1 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs @@ -44,6 +44,7 @@ IReadOnlyList Diagnostics Emitter.GeneratePayloadSchema(context, protocol); Emitter.GenerateClientExtensions(context, protocol); Emitter.GenerateHandleOverloads(context, protocol); + Emitter.GeneratePayloadHandles(context, protocol); Emitter.GenerateEventPayloads(context, protocol); Emitter.GenerateEventArgs(context, protocol); Emitter.GenerateClientEventInfrastructure(context, protocol); diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs new file mode 100644 index 0000000..361fe77 --- /dev/null +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs @@ -0,0 +1,303 @@ +// +#nullable enable + +using System; +using ObsWebSocket.Core.Protocol.Common; + +namespace ObsWebSocket.Core; + +/// +/// Handles for the things an event or a response already identifies by uuid. +/// +public static class ObsWebSocketPayloadHandles +{ + extension(ObsWebSocket.Core.Protocol.Events.CanvasCreatedPayload payload) + { + /// The canvas this message identifies, addressed by uuid so a rename cannot move it. + public CanvasHandle Canvas => CanvasHandle.FromUuid(payload.CanvasUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.CanvasNameChangedPayload payload) + { + /// The canvas this message identifies, addressed by uuid so a rename cannot move it. + public CanvasHandle Canvas => CanvasHandle.FromUuid(payload.CanvasUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.CanvasRemovedPayload payload) + { + /// The canvas this message identifies, addressed by uuid so a rename cannot move it. + public CanvasHandle Canvas => CanvasHandle.FromUuid(payload.CanvasUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.CurrentPreviewSceneChangedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.CurrentProgramSceneChangedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputActiveStateChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputAudioBalanceChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputAudioMonitorTypeChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputAudioSyncOffsetChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputAudioTracksChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputCreatedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputMuteStateChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputNameChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputRemovedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputSettingsChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputShowStateChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputVolumeChangedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.MediaInputActionTriggeredPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.MediaInputPlaybackEndedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.MediaInputPlaybackStartedPayload payload) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(payload.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneCreatedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneItemCreatedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + /// The source this message identifies, addressed by uuid so a rename cannot move it. + public SourceHandle Source => SourceHandle.FromUuid(payload.SourceUuid); + + /// The scene item this message is about, ready to act on without a lookup. + public SceneItemHandle SceneItem => SceneItemHandle.For(SceneHandle.FromUuid(payload.SceneUuid), payload.SceneItemId); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneItemEnableStateChangedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + /// The scene item this message is about, ready to act on without a lookup. + public SceneItemHandle SceneItem => SceneItemHandle.For(SceneHandle.FromUuid(payload.SceneUuid), payload.SceneItemId); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneItemListReindexedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneItemLockStateChangedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + /// The scene item this message is about, ready to act on without a lookup. + public SceneItemHandle SceneItem => SceneItemHandle.For(SceneHandle.FromUuid(payload.SceneUuid), payload.SceneItemId); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneItemRemovedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + /// The source this message identifies, addressed by uuid so a rename cannot move it. + public SourceHandle Source => SourceHandle.FromUuid(payload.SourceUuid); + + /// The scene item this message is about, ready to act on without a lookup. + public SceneItemHandle SceneItem => SceneItemHandle.For(SceneHandle.FromUuid(payload.SceneUuid), payload.SceneItemId); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneItemSelectedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + /// The scene item this message is about, ready to act on without a lookup. + public SceneItemHandle SceneItem => SceneItemHandle.For(SceneHandle.FromUuid(payload.SceneUuid), payload.SceneItemId); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneItemTransformChangedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + /// The scene item this message is about, ready to act on without a lookup. + public SceneItemHandle SceneItem => SceneItemHandle.For(SceneHandle.FromUuid(payload.SceneUuid), payload.SceneItemId); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneNameChangedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SceneRemovedPayload payload) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(payload.SceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.CreateInputResponseData response) + { + /// The input this message identifies, addressed by uuid so a rename cannot move it. + public InputHandle Input => InputHandle.FromUuid(response.InputUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.CreateSceneResponseData response) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(response.SceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetCurrentPreviewSceneResponseData response) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(response.SceneUuid); + + /// The preview scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle CurrentPreviewScene => SceneHandle.FromUuid(response.CurrentPreviewSceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetCurrentProgramSceneResponseData response) + { + /// The scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle Scene => SceneHandle.FromUuid(response.SceneUuid); + + /// The program scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle CurrentProgramScene => SceneHandle.FromUuid(response.CurrentProgramSceneUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetSceneItemSourceResponseData response) + { + /// The source this message identifies, addressed by uuid so a rename cannot move it. + public SourceHandle Source => SourceHandle.FromUuid(response.SourceUuid); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetSceneListResponseData response) + { + /// The program scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle? CurrentProgramScene => + string.IsNullOrEmpty(response.CurrentProgramSceneUuid) ? null : SceneHandle.FromUuid(response.CurrentProgramSceneUuid); + + /// The preview scene this message identifies, addressed by uuid so a rename cannot move it. + public SceneHandle? CurrentPreviewScene => + string.IsNullOrEmpty(response.CurrentPreviewSceneUuid) ? null : SceneHandle.FromUuid(response.CurrentPreviewSceneUuid); + + } + +} + +// Handles reachable without a lookup: 47 diff --git a/ObsWebSocket.Tests/HandleTests.cs b/ObsWebSocket.Tests/HandleTests.cs index 1c52e2d..c946d18 100644 --- a/ObsWebSocket.Tests/HandleTests.cs +++ b/ObsWebSocket.Tests/HandleTests.cs @@ -1,4 +1,6 @@ using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Events; +using ObsWebSocket.Core.Protocol.Responses; namespace ObsWebSocket.Tests; @@ -169,4 +171,69 @@ public void AHandleSaysWhatItAddressesWhenPrinted() SceneHandle.FromName("Intro").Item(3).ToString() ); } + + /// + /// An event already says which scene, by uuid. Reading the name back off it and addressing by + /// name again is the round trip and the race the uuid was there to avoid. + /// + [TestMethod] + public void AnEventCarriesAResolvedHandle() + { + CurrentProgramSceneChangedPayload changed = new( + sceneName: "Intro", + sceneUuid: "5d5db648-93a5-4985-bff8-45f4c9fe15f7" + ); + + Assert.IsTrue(changed.Scene.IsResolved); + Assert.AreEqual("5d5db648-93a5-4985-bff8-45f4c9fe15f7", changed.Scene.Uuid); + Assert.IsNull(changed.Scene.Name); + } + + /// + /// A scene item needs both halves, and an event that reports one carries both. + /// + [TestMethod] + public void AnEventCarriesAResolvedSceneItem() + { + SceneItemEnableStateChangedPayload changed = new( + sceneName: "Intro", + sceneUuid: "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + sceneItemId: 7, + sceneItemEnabled: true + ); + + Assert.AreEqual(7, changed.SceneItem.SceneItemId); + Assert.IsTrue(changed.SceneItem.Scene.IsResolved); + } + + /// + /// Creating something answers with its uuid, so the handle for it costs no second request. + /// + [TestMethod] + public void CreatingSomethingAnswersWithAHandle() + { + CreateSceneResponseData created = new(sceneUuid: "5d5db648-93a5-4985-bff8-45f4c9fe15f7"); + + Assert.IsTrue(created.Scene.IsResolved); + Assert.AreEqual("5d5db648-93a5-4985-bff8-45f4c9fe15f7", created.Scene.Uuid); + } + + /// + /// The preview scene is null outside studio mode, and the protocol says so in prose rather + /// than by marking the field optional. The accessor has to be nullable to match. + /// + [TestMethod] + public void AUuidTheProtocolSaysCanBeNullYieldsANullHandle() + { + GetSceneListResponseData outsideStudioMode = new( + scenes: [], + currentProgramSceneName: "Intro", + currentProgramSceneUuid: "5d5db648-93a5-4985-bff8-45f4c9fe15f7", + currentPreviewSceneName: null, + currentPreviewSceneUuid: null + ); + + Assert.IsNotNull(outsideStudioMode.CurrentProgramScene); + Assert.IsNull(outsideStudioMode.CurrentPreviewScene); + } } From 58c6c23250a0a21b846cc4ef0fe3ac71d6744ca5 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 26 Aug 2026 20:27:52 +0200 Subject: [PATCH 05/11] feat(codegen): drop the entity from the method name, and navigate between handles client.SceneItem(logo).SetSceneItemEnabledAsync(false) says scene item twice, once in the thing addressed and once in the verb. The operations types name the request without it, and the protocol name stays in the documentation and on the category group. The set is named at once so a collision falls back to full names rather than one request shadowing another; none collide today. Navigation covers what the protocol does not describe: a scene contains items, an input carries filters, and both are sources. --- .../Generation/Emitter.HandleOverloads.cs | 98 +++++- .../ObsWebSocketClient.HandleOverloads.g.cs | 328 ++++++++++++++---- .../Handles/ObsOperationsNavigation.cs | 119 +++++++ ObsWebSocket.Example/Worker.cs | 42 ++- ObsWebSocket.Tests/HandleOperationTests.cs | 18 +- 5 files changed, 503 insertions(+), 102 deletions(-) create mode 100644 ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs index b6a7dfe..e653f9c 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs @@ -96,12 +96,23 @@ ProtocolDefinition protocol builder.AppendLine($" public {handleType} Handle => handle;"); builder.AppendLine(); - foreach ( - (RequestDefinition request, IReadOnlyList refs) in byKind[kind] - .OrderBy(r => r.Request.RequestType, StringComparer.Ordinal) - ) + var ordered = byKind[kind] + .OrderBy(r => r.Request.RequestType, StringComparer.Ordinal) + .ToList(); + Dictionary methodNames = ShortMethodNames( + kind, + ordered.ConvertAll(r => r.Request.RequestType) + ); + + foreach ((RequestDefinition request, IReadOnlyList refs) in ordered) { - EmitScopedRequest(context, builder, request, refs); + EmitScopedRequest( + context, + builder, + request, + refs, + methodNames[request.RequestType] + ); builder.AppendLine(); emitted++; } @@ -144,6 +155,71 @@ ProtocolDefinition protocol ); } + /// + /// The name each request takes on the operations type, with the entity it is already scoped to + /// removed. + /// + /// + /// client.SceneItem(logo).SetSceneItemEnabledAsync(false) says scene item twice, once in + /// the thing being addressed and once in the verb. Dropping the second reads better and loses + /// nothing, because the protocol name stays in the documentation and on the category group. + /// + /// The whole set is named at once so a collision can be detected: two requests that shorten to + /// the same thing both keep their full names rather than one silently shadowing the other. No + /// collision exists today; this is here for the refresh that introduces one. + /// + /// + private static Dictionary ShortMethodNames( + string kind, + List requestTypes + ) + { + string[] tokens = kind switch + { + "scene" => ["Scene"], + "input" => ["Input"], + "source" => ["Source"], + "sceneItem" => ["SceneItem"], + "filter" => ["SourceFilter", "Filter"], + _ => [], + }; + + Dictionary shortened = new(StringComparer.Ordinal); + Dictionary counts = new(StringComparer.Ordinal); + + foreach (string requestType in requestTypes) + { + string candidate = requestType; + foreach (string token in tokens) + { + int at = candidate.IndexOf(token, StringComparison.Ordinal); + if (at >= 0) + { + candidate = candidate.Remove(at, token.Length); + break; + } + } + + if (candidate.Length == 0) + { + candidate = requestType; + } + + shortened[requestType] = candidate; + counts[candidate] = counts.TryGetValue(candidate, out int n) ? n + 1 : 1; + } + + foreach (string requestType in requestTypes) + { + if (counts[shortened[requestType]] > 1) + { + shortened[requestType] = requestType; + } + } + + return shortened; + } + private static string OpsTypeName(string kind) => kind switch { @@ -179,11 +255,12 @@ private static void EmitScopedRequest( SourceProductionContext context, StringBuilder builder, RequestDefinition request, - IReadOnlyList references + IReadOnlyList references, + string shortName ) { string baseName = SanitizeIdentifier(request.RequestType); - string methodName = baseName + "Async"; + string methodName = SanitizeIdentifier(shortName) + "Async"; string requestDto = $"{GeneratedRequestsNamespace}.{baseName}RequestData"; bool hasResponse = request.ResponseFields?.Count > 0; string returnType = hasResponse @@ -259,6 +336,11 @@ .. EntityReferenceTable.ArgumentsFor(references[0], "handle"), builder.AppendLine(" /// "); AppendMultiLineXmlDoc(builder, request.Description, " ///"); builder.AppendLine(" /// "); + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// Sends the {request.RequestType} request, with the identity supplied by the handle." + ); + builder.AppendLine(" /// "); foreach (string doc in docs) { builder.AppendLine(doc); @@ -283,7 +365,7 @@ .. EntityReferenceTable.ArgumentsFor(references[0], "handle"), builder.AppendLine($" public {returnType} {methodName}("); builder.AppendLine(" " + string.Join(",\n ", parameters)); builder.AppendLine(" ) =>"); - builder.AppendLine($" client.{groupName}.{methodName}("); + builder.AppendLine($" client.{groupName}.{baseName}Async("); builder.AppendLine($" new {requestDto}("); builder.AppendLine( " " diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs index 23b29b5..b14aa9d 100644 --- a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs @@ -23,11 +23,14 @@ public readonly partial struct FilterOperations(ObsWebSocketClient client, Filte /// /// Creates a new filter, adding it to the specified source. /// + /// + /// Sends the CreateSourceFilter request, with the identity supplied by the handle. + /// /// The kind of filter to be created /// Settings object to initialize the filter with /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task CreateSourceFilterAsync( + public Task CreateAsync( string filterKind, System.Text.Json.JsonElement? filterSettings = null, CancellationToken cancellationToken = default @@ -47,9 +50,12 @@ public Task CreateSourceFilterAsync( /// /// Gets the info for a specific source filter. /// + /// + /// Sends the GetSourceFilter request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSourceFilterAsync( + public Task GetAsync( CancellationToken cancellationToken = default ) => client.Filters.GetSourceFilterAsync( @@ -65,9 +71,12 @@ public Task CreateSourceFilterAsync( /// /// Removes a filter from a source. /// + /// + /// Sends the RemoveSourceFilter request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task RemoveSourceFilterAsync( + public Task RemoveAsync( CancellationToken cancellationToken = default ) => client.Filters.RemoveSourceFilterAsync( @@ -83,10 +92,13 @@ public Task RemoveSourceFilterAsync( /// /// Sets the enable state of a source filter. /// + /// + /// Sends the SetSourceFilterEnabled request, with the identity supplied by the handle. + /// /// New enable state of the filter /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSourceFilterEnabledAsync( + public Task SetEnabledAsync( bool filterEnabled, CancellationToken cancellationToken = default ) => @@ -104,10 +116,13 @@ public Task SetSourceFilterEnabledAsync( /// /// Sets the index position of a filter on a source. /// + /// + /// Sends the SetSourceFilterIndex request, with the identity supplied by the handle. + /// /// New index position of the filter /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSourceFilterIndexAsync( + public Task SetIndexAsync( int filterIndex, CancellationToken cancellationToken = default ) => @@ -125,10 +140,13 @@ public Task SetSourceFilterIndexAsync( /// /// Sets the name of a source filter (rename). /// + /// + /// Sends the SetSourceFilterName request, with the identity supplied by the handle. + /// /// New name for the filter /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSourceFilterNameAsync( + public Task SetNameAsync( string newFilterName, CancellationToken cancellationToken = default ) => @@ -146,11 +164,14 @@ public Task SetSourceFilterNameAsync( /// /// Sets the settings of a source filter. /// + /// + /// Sends the SetSourceFilterSettings request, with the identity supplied by the handle. + /// /// Object of settings to apply /// True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings. /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSourceFilterSettingsAsync( + public Task SetSettingsAsync( System.Text.Json.JsonElement? filterSettings, bool? overlay = null, CancellationToken cancellationToken = default @@ -182,9 +203,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Gets the audio balance of an input. /// + /// + /// Sends the GetInputAudioBalance request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputAudioBalanceAsync( + public Task GetAudioBalanceAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputAudioBalanceAsync( @@ -204,9 +228,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// - `OBS_MONITORING_TYPE_MONITOR_ONLY` /// - `OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT` /// + /// + /// Sends the GetInputAudioMonitorType request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputAudioMonitorTypeAsync( + public Task GetAudioMonitorTypeAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputAudioMonitorTypeAsync( @@ -222,9 +249,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Note: The audio sync offset can be negative too! /// + /// + /// Sends the GetInputAudioSyncOffset request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputAudioSyncOffsetAsync( + public Task GetAudioSyncOffsetAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputAudioSyncOffsetAsync( @@ -238,9 +268,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Gets the enable state of all audio tracks of an input. /// + /// + /// Sends the GetInputAudioTracks request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputAudioTracksAsync( + public Task GetAudioTracksAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputAudioTracksAsync( @@ -261,9 +294,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Note: Deinterlacing functionality is restricted to async inputs only. /// + /// + /// Sends the GetInputDeinterlaceFieldOrder request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputDeinterlaceFieldOrderAsync( + public Task GetDeinterlaceFieldOrderAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputDeinterlaceFieldOrderAsync( @@ -291,9 +327,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Note: Deinterlacing functionality is restricted to async inputs only. /// + /// + /// Sends the GetInputDeinterlaceMode request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputDeinterlaceModeAsync( + public Task GetDeinterlaceModeAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputDeinterlaceModeAsync( @@ -307,9 +346,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Gets the audio mute state of an input. /// + /// + /// Sends the GetInputMute request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputMuteAsync( + public Task GetMuteAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputMuteAsync( @@ -325,10 +367,13 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Note: 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. /// + /// + /// Sends the GetInputPropertiesListPropertyItems request, with the identity supplied by the handle. + /// /// Name of the list property to get the items of /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputPropertiesListPropertyItemsAsync( + public Task GetPropertiesListPropertyItemsAsync( string propertyName, CancellationToken cancellationToken = default ) => @@ -346,9 +391,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Note: Does not include defaults. To create the entire settings object, overlay `inputSettings` over the `defaultInputSettings` provided by `GetInputDefaultSettings`. /// + /// + /// Sends the GetInputSettings request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputSettingsAsync( + public Task GetSettingsAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputSettingsAsync( @@ -362,9 +410,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// Gets the current volume setting of an input. /// + /// + /// Sends the GetInputVolume request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetInputVolumeAsync( + public Task GetVolumeAsync( CancellationToken cancellationToken = default ) => client.Inputs.GetInputVolumeAsync( @@ -389,9 +440,12 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// - `OBS_MEDIA_STATE_ENDED` /// - `OBS_MEDIA_STATE_ERROR` /// + /// + /// Sends the GetMediaInputStatus request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetMediaInputStatusAsync( + public Task GetMediaStatusAsync( CancellationToken cancellationToken = default ) => client.MediaInputs.GetMediaInputStatusAsync( @@ -407,10 +461,13 @@ public readonly partial struct InputOperations(ObsWebSocketClient client, InputH /// /// This request does not perform bounds checking of the cursor position. /// + /// + /// Sends the OffsetMediaInputCursor request, with the identity supplied by the handle. + /// /// Value to offset the current cursor position by /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task OffsetMediaInputCursorAsync( + public Task OffsetMediaCursorAsync( long mediaCursorOffset, CancellationToken cancellationToken = default ) => @@ -426,9 +483,12 @@ public Task OffsetMediaInputCursorAsync( /// /// Opens the filters dialog of an input. /// + /// + /// Sends the OpenInputFiltersDialog request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task OpenInputFiltersDialogAsync( + public Task OpenFiltersDialogAsync( CancellationToken cancellationToken = default ) => client.Ui.OpenInputFiltersDialogAsync( @@ -442,9 +502,12 @@ public Task OpenInputFiltersDialogAsync( /// /// Opens the interact dialog of an input. /// + /// + /// Sends the OpenInputInteractDialog request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task OpenInputInteractDialogAsync( + public Task OpenInteractDialogAsync( CancellationToken cancellationToken = default ) => client.Ui.OpenInputInteractDialogAsync( @@ -458,9 +521,12 @@ public Task OpenInputInteractDialogAsync( /// /// Opens the properties dialog of an input. /// + /// + /// Sends the OpenInputPropertiesDialog request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task OpenInputPropertiesDialogAsync( + public Task OpenPropertiesDialogAsync( CancellationToken cancellationToken = default ) => client.Ui.OpenInputPropertiesDialogAsync( @@ -480,10 +546,13 @@ public Task OpenInputPropertiesDialogAsync( /// /// Note: 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. /// + /// + /// Sends the PressInputPropertiesButton request, with the identity supplied by the handle. + /// /// Name of the button property to press /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task PressInputPropertiesButtonAsync( + public Task PressPropertiesButtonAsync( string propertyName, CancellationToken cancellationToken = default ) => @@ -501,9 +570,12 @@ public Task PressInputPropertiesButtonAsync( /// /// Note: Will immediately remove all associated scene items. /// + /// + /// Sends the RemoveInput request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task RemoveInputAsync( + public Task RemoveAsync( CancellationToken cancellationToken = default ) => client.Inputs.RemoveInputAsync( @@ -517,10 +589,13 @@ public Task RemoveInputAsync( /// /// Sets the audio balance of an input. /// + /// + /// Sends the SetInputAudioBalance request, with the identity supplied by the handle. + /// /// New audio balance value /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputAudioBalanceAsync( + public Task SetAudioBalanceAsync( double inputAudioBalance, CancellationToken cancellationToken = default ) => @@ -536,10 +611,13 @@ public Task SetInputAudioBalanceAsync( /// /// Sets the audio monitor type of an input. /// + /// + /// Sends the SetInputAudioMonitorType request, with the identity supplied by the handle. + /// /// Audio monitor type /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputAudioMonitorTypeAsync( + public Task SetAudioMonitorTypeAsync( string monitorType, CancellationToken cancellationToken = default ) => @@ -555,10 +633,13 @@ public Task SetInputAudioMonitorTypeAsync( /// /// Sets the audio sync offset of an input. /// + /// + /// Sends the SetInputAudioSyncOffset request, with the identity supplied by the handle. + /// /// New audio sync offset in milliseconds /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputAudioSyncOffsetAsync( + public Task SetAudioSyncOffsetAsync( int inputAudioSyncOffset, CancellationToken cancellationToken = default ) => @@ -574,10 +655,13 @@ public Task SetInputAudioSyncOffsetAsync( /// /// Sets the enable state of audio tracks of an input. /// + /// + /// Sends the SetInputAudioTracks request, with the identity supplied by the handle. + /// /// Track settings to apply /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputAudioTracksAsync( + public Task SetAudioTracksAsync( System.Collections.Generic.Dictionary? inputAudioTracks, CancellationToken cancellationToken = default ) => @@ -595,10 +679,13 @@ public Task SetInputAudioTracksAsync( /// /// Note: Deinterlacing functionality is restricted to async inputs only. /// + /// + /// Sends the SetInputDeinterlaceFieldOrder request, with the identity supplied by the handle. + /// /// Deinterlace field order for the input /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputDeinterlaceFieldOrderAsync( + public Task SetDeinterlaceFieldOrderAsync( string inputDeinterlaceFieldOrder, CancellationToken cancellationToken = default ) => @@ -616,10 +703,13 @@ public Task SetInputDeinterlaceFieldOrderAsync( /// /// Note: Deinterlacing functionality is restricted to async inputs only. /// + /// + /// Sends the SetInputDeinterlaceMode request, with the identity supplied by the handle. + /// /// Deinterlace mode for the input /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputDeinterlaceModeAsync( + public Task SetDeinterlaceModeAsync( string inputDeinterlaceMode, CancellationToken cancellationToken = default ) => @@ -635,10 +725,13 @@ public Task SetInputDeinterlaceModeAsync( /// /// Sets the audio mute state of an input. /// + /// + /// Sends the SetInputMute request, with the identity supplied by the handle. + /// /// Whether to mute the input or not /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputMuteAsync( + public Task SetMuteAsync( bool inputMuted, CancellationToken cancellationToken = default ) => @@ -654,10 +747,13 @@ public Task SetInputMuteAsync( /// /// Sets the name of an input (rename). /// + /// + /// Sends the SetInputName request, with the identity supplied by the handle. + /// /// New name for the input /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputNameAsync( + public Task SetNameAsync( string newInputName, CancellationToken cancellationToken = default ) => @@ -673,11 +769,14 @@ public Task SetInputNameAsync( /// /// Sets the settings of an input. /// + /// + /// Sends the SetInputSettings request, with the identity supplied by the handle. + /// /// Object of settings to apply /// True == apply the settings on top of existing ones, False == reset the input to its defaults, then apply settings. /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputSettingsAsync( + public Task SetSettingsAsync( System.Text.Json.JsonElement? inputSettings, bool? overlay = null, CancellationToken cancellationToken = default @@ -695,11 +794,14 @@ public Task SetInputSettingsAsync( /// /// Sets the volume setting of an input. /// + /// + /// Sends the SetInputVolume request, with the identity supplied by the handle. + /// /// Volume setting in mul /// Volume setting in dB /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetInputVolumeAsync( + public Task SetVolumeAsync( double? inputVolumeMul = null, double? inputVolumeDb = null, CancellationToken cancellationToken = default @@ -719,10 +821,13 @@ public Task SetInputVolumeAsync( /// /// This request does not perform bounds checking of the cursor position. /// + /// + /// Sends the SetMediaInputCursor request, with the identity supplied by the handle. + /// /// New cursor position to set /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetMediaInputCursorAsync( + public Task SetMediaCursorAsync( long mediaCursor, CancellationToken cancellationToken = default ) => @@ -738,9 +843,12 @@ public Task SetMediaInputCursorAsync( /// /// Toggles the audio mute state of an input. /// + /// + /// Sends the ToggleInputMute request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task ToggleInputMuteAsync( + public Task ToggleMuteAsync( CancellationToken cancellationToken = default ) => client.Inputs.ToggleInputMuteAsync( @@ -754,10 +862,13 @@ public Task SetMediaInputCursorAsync( /// /// Triggers an action on a media input. /// + /// + /// Sends the TriggerMediaInputAction request, with the identity supplied by the handle. + /// /// Identifier of the `ObsMediaInputAction` enum /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task TriggerMediaInputActionAsync( + public Task TriggerMediaActionAsync( ObsWebSocket.Core.Protocol.Generated.MediaInputAction mediaAction, CancellationToken cancellationToken = default ) => @@ -785,6 +896,9 @@ public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneH /// /// Creates a new input, adding it as a scene item to the specified scene. /// + /// + /// Sends the CreateInput request, with the identity supplied by the handle. + /// /// Name of the new input to created /// The kind of input to be created /// Settings object to initialize the input with @@ -816,11 +930,14 @@ public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneH /// /// Scenes only /// + /// + /// Sends the CreateSceneItem request, with the identity supplied by the handle. + /// /// The source to use. /// Enable state to apply to the scene item on creation /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task CreateSceneItemAsync( + public Task CreateItemAsync( SourceHandle source, bool? sceneItemEnabled = null, CancellationToken cancellationToken = default @@ -844,9 +961,12 @@ public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneH /// /// Groups only /// + /// + /// Sends the GetGroupSceneItemList request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetGroupSceneItemListAsync( + public Task GetGroupItemListAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetGroupSceneItemListAsync( @@ -863,11 +983,14 @@ public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneH /// /// Scenes and Groups /// + /// + /// Sends the GetSceneItemId request, with the identity supplied by the handle. + /// /// Name of the source to find /// Number of matches to skip during search. >= 0 means first forward. -1 means last (top) item /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemIdAsync( + public Task GetItemIdAsync( string sourceName, int? searchOffset = null, CancellationToken cancellationToken = default @@ -888,9 +1011,12 @@ public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneH /// /// Scenes only /// + /// + /// Sends the GetSceneItemList request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemListAsync( + public Task GetItemListAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetSceneItemListAsync( @@ -907,9 +1033,12 @@ public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneH /// /// Note: A transition UUID response field is not currently able to be implemented as of 2024-1-18. /// + /// + /// Sends the GetSceneSceneTransitionOverride request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneSceneTransitionOverrideAsync( + public Task GetSceneTransitionOverrideAsync( CancellationToken cancellationToken = default ) => client.Scenes.GetSceneSceneTransitionOverrideAsync( @@ -924,9 +1053,12 @@ public readonly partial struct SceneOperations(ObsWebSocketClient client, SceneH /// /// Removes a scene from OBS. /// + /// + /// Sends the RemoveScene request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task RemoveSceneAsync( + public Task RemoveAsync( CancellationToken cancellationToken = default ) => client.Scenes.RemoveSceneAsync( @@ -943,9 +1075,12 @@ public Task RemoveSceneAsync( /// /// Only available when studio mode is enabled. /// + /// + /// Sends the SetCurrentPreviewScene request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetCurrentPreviewSceneAsync( + public Task SetCurrentPreviewAsync( CancellationToken cancellationToken = default ) => client.Scenes.SetCurrentPreviewSceneAsync( @@ -959,9 +1094,12 @@ public Task SetCurrentPreviewSceneAsync( /// /// Sets the current program scene. /// + /// + /// Sends the SetCurrentProgramScene request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetCurrentProgramSceneAsync( + public Task SetCurrentProgramAsync( CancellationToken cancellationToken = default ) => client.Scenes.SetCurrentProgramSceneAsync( @@ -975,10 +1113,13 @@ public Task SetCurrentProgramSceneAsync( /// /// Sets the name of a scene (rename). /// + /// + /// Sends the SetSceneName request, with the identity supplied by the handle. + /// /// New name for the scene /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSceneNameAsync( + public Task SetNameAsync( string newSceneName, CancellationToken cancellationToken = default ) => @@ -995,11 +1136,14 @@ public Task SetSceneNameAsync( /// /// Sets the scene transition overridden for a scene. /// + /// + /// Sends the SetSceneSceneTransitionOverride request, with the identity supplied by the handle. + /// /// Name of the scene transition to use as override. Specify `null` to remove /// Duration to use for any overridden transition. Specify `null` to remove /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSceneSceneTransitionOverrideAsync( + public Task SetSceneTransitionOverrideAsync( string? transitionName = null, int? transitionDuration = null, CancellationToken cancellationToken = default @@ -1032,10 +1176,13 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Scenes only /// + /// + /// Sends the DuplicateSceneItem request, with the identity supplied by the handle. + /// /// The destinationScene to use. /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task DuplicateSceneItemAsync( + public Task DuplicateAsync( SceneHandle destinationScene, CancellationToken cancellationToken = default ) => @@ -1066,9 +1213,12 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Scenes and Groups /// + /// + /// Sends the GetSceneItemBlendMode request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemBlendModeAsync( + public Task GetBlendModeAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetSceneItemBlendModeAsync( @@ -1086,9 +1236,12 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Scenes and Groups /// + /// + /// Sends the GetSceneItemEnabled request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemEnabledAsync( + public Task GetEnabledAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetSceneItemEnabledAsync( @@ -1108,9 +1261,12 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Scenes and Groups /// + /// + /// Sends the GetSceneItemIndex request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemIndexAsync( + public Task GetIndexAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetSceneItemIndexAsync( @@ -1128,9 +1284,12 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Scenes and Groups /// + /// + /// Sends the GetSceneItemLocked request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemLockedAsync( + public Task GetLockedAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetSceneItemLockedAsync( @@ -1146,9 +1305,12 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Gets the source associated with a scene item. /// + /// + /// Sends the GetSceneItemSource request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemSourceAsync( + public Task GetSourceAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetSceneItemSourceAsync( @@ -1166,9 +1328,12 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Scenes and Groups /// + /// + /// Sends the GetSceneItemTransform request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSceneItemTransformAsync( + public Task GetTransformAsync( CancellationToken cancellationToken = default ) => client.SceneItems.GetSceneItemTransformAsync( @@ -1186,9 +1351,12 @@ public readonly partial struct SceneItemOperations(ObsWebSocketClient client, Sc /// /// Scenes only /// + /// + /// Sends the RemoveSceneItem request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task RemoveSceneItemAsync( + public Task RemoveAsync( CancellationToken cancellationToken = default ) => client.SceneItems.RemoveSceneItemAsync( @@ -1206,10 +1374,13 @@ public Task RemoveSceneItemAsync( /// /// Scenes and Groups /// + /// + /// Sends the SetSceneItemBlendMode request, with the identity supplied by the handle. + /// /// New blend mode /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSceneItemBlendModeAsync( + public Task SetBlendModeAsync( string sceneItemBlendMode, CancellationToken cancellationToken = default ) => @@ -1229,10 +1400,13 @@ public Task SetSceneItemBlendModeAsync( /// /// Scenes and Groups /// + /// + /// Sends the SetSceneItemEnabled request, with the identity supplied by the handle. + /// /// New enable state of the scene item /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSceneItemEnabledAsync( + public Task SetEnabledAsync( bool sceneItemEnabled, CancellationToken cancellationToken = default ) => @@ -1252,10 +1426,13 @@ public Task SetSceneItemEnabledAsync( /// /// Scenes and Groups /// + /// + /// Sends the SetSceneItemIndex request, with the identity supplied by the handle. + /// /// New index position of the scene item /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSceneItemIndexAsync( + public Task SetIndexAsync( int sceneItemIndex, CancellationToken cancellationToken = default ) => @@ -1275,10 +1452,13 @@ public Task SetSceneItemIndexAsync( /// /// Scenes and Group /// + /// + /// Sends the SetSceneItemLocked request, with the identity supplied by the handle. + /// /// New lock state of the scene item /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSceneItemLockedAsync( + public Task SetLockedAsync( bool sceneItemLocked, CancellationToken cancellationToken = default ) => @@ -1296,10 +1476,13 @@ public Task SetSceneItemLockedAsync( /// /// Sets the transform and crop info of a scene item. /// + /// + /// Sends the SetSceneItemTransform request, with the identity supplied by the handle. + /// /// Object containing scene item transform info to update /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SetSceneItemTransformAsync( + public Task SetTransformAsync( ObsWebSocket.Core.Protocol.Common.SceneItemTransformPatchStub? sceneItemTransform, CancellationToken cancellationToken = default ) => @@ -1331,9 +1514,12 @@ public readonly partial struct SourceOperations(ObsWebSocketClient client, Sourc /// /// **Compatible with inputs and scenes.** /// + /// + /// Sends the GetSourceActive request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSourceActiveAsync( + public Task GetActiveAsync( CancellationToken cancellationToken = default ) => client.Sources.GetSourceActiveAsync( @@ -1348,9 +1534,12 @@ public readonly partial struct SourceOperations(ObsWebSocketClient client, Sourc /// /// Gets an array of all of a source's filters. /// + /// + /// Sends the GetSourceFilterList request, with the identity supplied by the handle. + /// /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSourceFilterListAsync( + public Task GetFilterListAsync( CancellationToken cancellationToken = default ) => client.Filters.GetSourceFilterListAsync( @@ -1370,13 +1559,16 @@ public readonly partial struct SourceOperations(ObsWebSocketClient client, Sourc /// /// **Compatible with inputs and scenes.** /// + /// + /// Sends the GetSourceScreenshot request, with the identity supplied by the handle. + /// /// Image compression format to use. Use `GetVersion` to get compatible image formats /// Width to scale the screenshot to /// Height to scale the screenshot to /// Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use "default" (whatever that means, idk) /// A token to cancel the asynchronous operation. /// A task yielding the response data. - public Task GetSourceScreenshotAsync( + public Task GetScreenshotAsync( string imageFormat, int? imageWidth = null, int? imageHeight = null, @@ -1401,11 +1593,14 @@ public readonly partial struct SourceOperations(ObsWebSocketClient client, Sourc /// /// Note: This request serves to provide feature parity with 4.x. It is very likely to be changed/deprecated in a future release. /// + /// + /// Sends the OpenSourceProjector request, with the identity supplied by the handle. + /// /// Monitor index, use `GetMonitorList` to obtain index /// Size/Position data for a windowed projector, in Qt Base64 encoded format. Mutually exclusive with `monitorIndex` /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task OpenSourceProjectorAsync( + public Task OpenProjectorAsync( int? monitorIndex = null, string? projectorGeometry = null, CancellationToken cancellationToken = default @@ -1429,6 +1624,9 @@ public Task OpenSourceProjectorAsync( /// /// **Compatible with inputs and scenes.** /// + /// + /// Sends the SaveSourceScreenshot request, with the identity supplied by the handle. + /// /// Image compression format to use. Use `GetVersion` to get compatible image formats /// Path to save the screenshot file to. Eg. `C:\Users\user\Desktop\screenshot.png` /// Width to scale the screenshot to @@ -1436,7 +1634,7 @@ public Task OpenSourceProjectorAsync( /// Compression quality to use. 0 for high compression, 100 for uncompressed. -1 to use "default" (whatever that means, idk) /// A token to cancel the asynchronous operation. /// A task that completes when OBS has processed the request. - public Task SaveSourceScreenshotAsync( + public Task SaveScreenshotAsync( string imageFormat, string imageFilePath, int? imageWidth = null, diff --git a/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs b/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs new file mode 100644 index 0000000..cc8df4c --- /dev/null +++ b/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs @@ -0,0 +1,119 @@ +namespace ObsWebSocket.Core; + +// Getting from one addressed thing to another without going back through the client. +// +// The operations types are generated from the protocol, which knows nothing about a scene +// containing items or an input carrying filters. That relationship is real and worth navigating, +// so it is written here rather than inferred. + +public readonly partial struct SceneOperations +{ + /// + /// Looks up this scene's uuid, so later requests survive a rename. + /// + /// A token to cancel the lookup. + /// + /// Thrown when no scene has that name, listing the scenes that do. + /// + public async ValueTask ResolveAsync( + CancellationToken cancellationToken = default + ) => + new( + client, + await client.Scenes.ResolveAsync(handle, cancellationToken).ConfigureAwait(false) + ); + + /// A scene item in this scene, by the numeric id OBS assigned it. + /// The id, which GetSceneItemList or an event reports. + public SceneItemOperations Item(int sceneItemId) => new(client, handle.Item(sceneItemId)); + + /// + /// A scene item in this scene, by the name of the source it shows. + /// + /// + /// Unlike the other lookups this one is not a convenience: OBS addresses scene items by a + /// number that only GetSceneItemId reports. + /// + /// The name of the source the item shows. + /// + /// Which match to take when the source appears more than once. 0 is the first from the bottom; + /// -1 is the topmost. + /// + /// A token to cancel the lookup. + /// + /// Thrown when the scene holds no such source, listing the sources it does hold. + /// + public async ValueTask ItemAsync( + string sourceName, + int searchOffset = 0, + CancellationToken cancellationToken = default + ) => + new( + client, + await client + .SceneItems.ResolveAsync(handle.Item(sourceName), searchOffset, cancellationToken) + .ConfigureAwait(false) + ); + + /// This scene addressed as a source, for the requests that take any source. + public SourceOperations AsSource() => new(client, handle.AsSource()); +} + +public readonly partial struct InputOperations +{ + /// + /// Looks up this input's uuid, so later requests survive a rename. + /// + /// A token to cancel the lookup. + /// + /// Thrown when no input has that name, listing the inputs that do. + /// + public async ValueTask ResolveAsync( + CancellationToken cancellationToken = default + ) => + new( + client, + await client.Inputs.ResolveAsync(handle, cancellationToken).ConfigureAwait(false) + ); + + /// A filter on this input, by name, which is a filter's whole identity. + /// The filter's name. + public FilterOperations Filter(string filterName) => new(client, handle.Filter(filterName)); + + /// This input addressed as a source, for the requests that take any source. + public SourceOperations AsSource() => new(client, handle.AsSource()); +} + +public readonly partial struct SourceOperations +{ + /// + /// Looks up this source's uuid. A source is a scene or an input, so this may take two lookups. + /// + /// A token to cancel the lookup. + /// + /// Thrown when neither the inputs nor the scenes have that name. + /// + public async ValueTask ResolveAsync( + CancellationToken cancellationToken = default + ) => + new( + client, + await client.Sources.ResolveAsync(handle, cancellationToken).ConfigureAwait(false) + ); + + /// A filter on this source, by name. + /// The filter's name. + public FilterOperations Filter(string filterName) => new(client, handle.Filter(filterName)); +} + +public readonly partial struct SceneItemOperations +{ + /// The scene this item lives in. + public SceneOperations Scene => new(client, handle.Scene); +} + +public readonly partial struct FilterOperations +{ + /// The source this filter is on. + public SourceOperations Source => new(client, handle.Source); +} diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index a2be61b..e4318f9 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -3021,23 +3021,25 @@ await TrySettingsCheckAsync( // The point of a uuid handle is that it survives a rename. Both forms // have to reach the same scene for that to be worth anything. SceneHandle byName = sceneName; - SceneHandle byUuid = await client - .Scenes.ResolveAsync(byName, cancellationToken) + SceneOperations resolved = await client + .Scene(byName) + .ResolveAsync(cancellationToken) .ConfigureAwait(false); + SceneHandle byUuid = resolved.Handle; GetSceneItemListResponseData viaName = await client .Scene(byName) - .GetSceneItemListAsync(cancellationToken) + .GetItemListAsync(cancellationToken) .ConfigureAwait(false); GetSceneItemListResponseData viaUuid = await client .Scene(byUuid) - .GetSceneItemListAsync(cancellationToken) + .GetItemListAsync(cancellationToken) .ConfigureAwait(false); string renamed = sceneName + "_renamed"; await client .Scene(byUuid) - .SetSceneNameAsync(renamed, cancellationToken) + .SetNameAsync(renamed, cancellationToken) .ConfigureAwait(false); bool uuidStillWorks; @@ -3045,7 +3047,7 @@ await client { _ = await client .Scene(byUuid) - .GetSceneItemListAsync(cancellationToken) + .GetItemListAsync(cancellationToken) .ConfigureAwait(false); uuidStillWorks = true; } @@ -3059,7 +3061,7 @@ await client { _ = await client .Scene(byName) - .GetSceneItemListAsync(cancellationToken) + .GetItemListAsync(cancellationToken) .ConfigureAwait(false); nameNowMisses = false; } @@ -3070,7 +3072,7 @@ await client await client .Scene(byUuid) - .SetSceneNameAsync(sceneName, CancellationToken.None) + .SetNameAsync(sceneName, CancellationToken.None) .ConfigureAwait(false); return ( @@ -3126,22 +3128,26 @@ await TrySettingsCheckAsync( { // The one lookup that is not a convenience: OBS addresses scene items // by a number nothing else reports. - SceneItemHandle item = await client - .SceneItems.ResolveAsync( - SceneHandle.FromName(sceneName).Item(inputName), - cancellationToken: cancellationToken + SceneItemOperations item = await client + .Scene(sceneName) + .ItemAsync(inputName, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + GetSceneItemEnabledResponseData enabled = await item.GetEnabledAsync( + cancellationToken ) .ConfigureAwait(false); - GetSceneItemEnabledResponseData enabled = await client - .SceneItem(item) - .GetSceneItemEnabledAsync(cancellationToken) + // Navigating back up reaches the scene the item is in. + GetSceneItemListResponseData siblings = await item + .Scene.GetItemListAsync(cancellationToken) .ConfigureAwait(false); return ( - item.SceneItemId >= 0, - $"'{inputName}' is item {item.SceneItemId}, enabled " - + $"{enabled.SceneItemEnabled}" + item.Handle.SceneItemId >= 0 && siblings.SceneItems.Count > 0, + $"'{inputName}' is item {item.Handle.SceneItemId}, enabled " + + $"{enabled.SceneItemEnabled}, among {siblings.SceneItems.Count} " + + "in its scene" ); } ) diff --git a/ObsWebSocket.Tests/HandleOperationTests.cs b/ObsWebSocket.Tests/HandleOperationTests.cs index 9b5f3d3..b1b2a93 100644 --- a/ObsWebSocket.Tests/HandleOperationTests.cs +++ b/ObsWebSocket.Tests/HandleOperationTests.cs @@ -24,7 +24,7 @@ public sealed class HandleOperationTests public async Task ANameHandleSendsTheNameAndNoUuid() { JsonElement sent = await CaptureAsync( - (client, ct) => client.Scene("Intro").SetCurrentProgramSceneAsync(ct) + (client, ct) => client.Scene("Intro").SetCurrentProgramAsync(ct) ); Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); @@ -39,7 +39,7 @@ public async Task ANameHandleSendsTheNameAndNoUuid() public async Task AUuidHandleSendsTheUuidAndNoName() { JsonElement sent = await CaptureAsync( - (client, ct) => client.Scene(s_uuid).SetCurrentProgramSceneAsync(ct) + (client, ct) => client.Scene(s_uuid).SetCurrentProgramAsync(ct) ); Assert.AreEqual( @@ -62,7 +62,7 @@ public async Task ACanvasScopedNameSendsTheCanvas() CanvasHandle vertical = CanvasHandle.FromUuid(s_uuid); JsonElement sent = await CaptureAsync( - (client, ct) => client.Scene(vertical.Scene("Intro")).GetSceneItemListAsync(ct) + (client, ct) => client.Scene(vertical.Scene("Intro")).GetItemListAsync(ct) ); Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); @@ -80,9 +80,7 @@ public async Task ASceneItemSendsItsSceneAndItsId() { JsonElement sent = await CaptureAsync( (client, ct) => - client - .SceneItem(SceneHandle.FromName("Intro").Item(7)) - .SetSceneItemEnabledAsync(false, ct) + client.SceneItem(SceneHandle.FromName("Intro").Item(7)).SetEnabledAsync(false, ct) ); Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); @@ -95,9 +93,7 @@ public async Task AFilterSendsItsSourceAndItsName() { JsonElement sent = await CaptureAsync( (client, ct) => - client - .Filter(InputHandle.FromName("Mic").Filter("EQ")) - .SetSourceFilterEnabledAsync(true, ct) + client.Filter(InputHandle.FromName("Mic").Filter("EQ")).SetEnabledAsync(true, ct) ); Assert.AreEqual("Mic", sent.GetProperty("sourceName").GetString()); @@ -116,7 +112,7 @@ public async Task ASecondReferenceIsAParameter() (client, ct) => client .SceneItem(SceneHandle.FromName("Intro").Item(7)) - .DuplicateSceneItemAsync(destinationScene: "Outro", cancellationToken: ct) + .DuplicateAsync(destinationScene: "Outro", cancellationToken: ct) ); Assert.AreEqual("Intro", sent.GetProperty("sceneName").GetString()); @@ -129,7 +125,7 @@ public async Task ASecondReferenceIsAParameter() public async Task AStringReachesTheOperationsWithoutCeremony() { JsonElement sent = await CaptureAsync( - (client, ct) => client.Input("Mic").ToggleInputMuteAsync(ct) + (client, ct) => client.Input("Mic").ToggleMuteAsync(ct) ); Assert.AreEqual("Mic", sent.GetProperty("inputName").GetString()); From c5dc24185374b9e5907ceed3a8f203ee71602b6e Mon Sep 17 00:00:00 2001 From: Agash Date: Mon, 31 Aug 2026 15:47:23 +0200 Subject: [PATCH 06/11] fix(core): widen the numbers OBS does not bound Found by the request sweep: GetOutputList became unreadable because an idle virtual camera reported outputHeight as 2586032160. The field is uint32_t in libobs and obs-websocket passes it through unclamped, so an int could not hold it, and one bad field fails the whole response rather than just itself. Swept the rest of the numeric table against the obs-websocket and obs-studio sources. A field is safe as an int only where obs-websocket validates a range; where it copies out of libobs or a settings blob the C type decides. That reclassifies: outputWidth/Height (stub) uint32_t, unclamped render/output frames uint32_t, monotonic webSocketSession messages uint64_t sceneItemId int64_t, validated >= 0 with no upper bound transitionDuration int64_t out of private settings alignment/boundsAlignment uint32_t, validated to the full range sceneIndex becomes nullable for the same reason: GetCanvasSceneList has no index to report and sends null. Removes FindSceneItemIdInt32Async, which named a return type it no longer has. --- .../Generation/NumericFieldTable.cs | 44 ++++- .../ObsWebSocketClient.HandleOverloads.g.cs | 2 +- ...ransitionDurationChanged.EventPayload.g.cs | 4 +- .../Events/SceneItemCreated.EventPayload.g.cs | 4 +- ...neItemEnableStateChanged.EventPayload.g.cs | 4 +- ...ceneItemLockStateChanged.EventPayload.g.cs | 4 +- .../Events/SceneItemRemoved.EventPayload.g.cs | 4 +- .../SceneItemSelected.EventPayload.g.cs | 4 +- ...ceneItemTransformChanged.EventPayload.g.cs | 4 +- .../Requests/DuplicateSceneItem.Request.g.cs | 4 +- .../GetSceneItemBlendMode.Request.g.cs | 4 +- .../Requests/GetSceneItemEnabled.Request.g.cs | 4 +- .../Requests/GetSceneItemIndex.Request.g.cs | 4 +- .../Requests/GetSceneItemLocked.Request.g.cs | 4 +- .../Requests/GetSceneItemSource.Request.g.cs | 4 +- .../GetSceneItemTransform.Request.g.cs | 4 +- .../Requests/RemoveSceneItem.Request.g.cs | 4 +- ...urrentSceneTransitionDuration.Request.g.cs | 4 +- .../SetSceneItemBlendMode.Request.g.cs | 4 +- .../Requests/SetSceneItemEnabled.Request.g.cs | 4 +- .../Requests/SetSceneItemIndex.Request.g.cs | 4 +- .../Requests/SetSceneItemLocked.Request.g.cs | 4 +- .../SetSceneItemTransform.Request.g.cs | 4 +- ...tSceneSceneTransitionOverride.Request.g.cs | 4 +- .../Responses/CreateInput.Response.g.cs | 4 +- .../Responses/CreateSceneItem.Response.g.cs | 4 +- .../DuplicateSceneItem.Response.g.cs | 4 +- .../GetCurrentSceneTransition.Response.g.cs | 4 +- .../Responses/GetOutputStatus.Response.g.cs | 6 +- .../Responses/GetSceneItemId.Response.g.cs | 4 +- ...SceneSceneTransitionOverride.Response.g.cs | 4 +- .../Protocol/Responses/GetStats.Response.g.cs | 14 +- .../Responses/GetStreamStatus.Response.g.cs | 6 +- ObsWebSocket.Core/Groups/SceneItemsGroup.cs | 21 +-- ObsWebSocket.Core/Handles/ObsHandles.cs | 8 +- .../Handles/ObsOperationsNavigation.cs | 2 +- .../Protocol/Common/StubTypes.cs | 56 ++++-- .../ObsWebSocketClientIntegrationTests.cs | 2 +- ObsWebSocket.Tests/StubArrayTests.cs | 173 ++++++++++++++++++ 39 files changed, 328 insertions(+), 118 deletions(-) diff --git a/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs b/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs index 1f3dee3..637c323 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs @@ -12,6 +12,15 @@ namespace ObsWebSocket.Codegen.Tasks.Generation; /// field missing from the table only stays double, which is what it would have been anyway. /// A refresh that introduces an unlisted Number field reports OBSWSGEN012 so it gets /// classified deliberately instead of drifting in. +/// +/// The rule for choosing between int and long is not how large the number looks. It +/// is whether obs-websocket bounds it. A field validated with ValidateNumber or +/// ValidateOptionalNumber has a stated range and is safe at its natural width; a field +/// copied straight out of libobs or a settings blob is as wide as the C type behind it, and most of +/// those are uint32_t or int64_t. Getting this wrong is not a truncated field — the +/// response fails to deserialize, so one out-of-range pixel count takes the whole message with it. +/// Check the request handler before adding a field here. +/// /// internal static class NumericFieldTable { @@ -19,7 +28,6 @@ internal static class NumericFieldTable private static readonly HashSet s_int32Fields = new(StringComparer.Ordinal) { // Identity and ordering. - "sceneItemId", "sceneItemIndex", "filterIndex", "monitorIndex", @@ -37,16 +45,8 @@ internal static class NumericFieldTable "fpsDenominator", // Durations and offsets that OBS reports in whole milliseconds or frames. "inputAudioSyncOffset", - "transitionDuration", "sleepFrames", "sleepMillis", - // Counters. - "renderSkippedFrames", - "renderTotalFrames", - "outputSkippedFrames", - "outputTotalFrames", - "webSocketSessionIncomingMessages", - "webSocketSessionOutgoingMessages", // Protocol version. "rpcVersion", }; @@ -55,6 +55,17 @@ internal static class NumericFieldTable /// Whole-number fields that can exceed 32 bits: byte counts, millisecond durations over a long /// session, and the input capability bitflag, which OBS defines as an unsigned 32 bit mask. /// + /// + /// The frame and message counters are here because of what fills them, not because the numbers + /// look large. A field is safe as an int only when obs-websocket bounds it — the + /// resolutions are all validated to 8..4096, and the indices are container positions. These + /// are copied out of libobs and the session with no clamp in between: + /// obs_get_total_frames and obs_get_lagged_frames return uint32_t, + /// video_output_get_skipped_frames returns uint32_t, and the session counters are + /// uint64_t. A monotonic frame counter passes after roughly + /// 414 days at 60fps, which a 24/7 instance reaches, and the whole response fails to + /// deserialize when it does. + /// private static readonly HashSet s_int64Fields = new(StringComparer.Ordinal) { "outputBytes", @@ -63,6 +74,21 @@ internal static class NumericFieldTable "mediaCursorOffset", "mediaDuration", "inputKindCaps", + // Counters, unclamped from uint32_t (frames) and uint64_t (session messages). + "renderSkippedFrames", + "renderTotalFrames", + "outputSkippedFrames", + "outputTotalFrames", + "webSocketSessionIncomingMessages", + "webSocketSessionOutgoingMessages", + // A scene item id is int64_t at every point OBS touches it, and the only bound + // obs-websocket applies is >= 0. Sequential assignment keeps real ids small, but the + // counter they come from is restored out of the scene collection file, and a plugin can + // choose an id outright, so neither the type nor the protocol makes 32 bits safe. + "sceneItemId", + // Read back out of a scene's private settings with obs_data_get_int, which is int64_t and + // is not revalidated on the way out. Only the write side is bounded to 50..20000. + "transitionDuration", }; /// diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs index b14aa9d..01e0823 100644 --- a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs @@ -1145,7 +1145,7 @@ public Task SetNameAsync( /// A task that completes when OBS has processed the request. public Task SetSceneTransitionOverrideAsync( string? transitionName = null, - int? transitionDuration = null, + long? transitionDuration = null, CancellationToken cancellationToken = default ) => client.Scenes.SetSceneSceneTransitionOverrideAsync( diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs index 443a405..670e30e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs @@ -29,7 +29,7 @@ public sealed partial record CurrentSceneTransitionDurationChangedPayload /// [JsonPropertyName("transitionDuration")] [Key("transitionDuration")] - public required int TransitionDuration { get; init; } + public required long TransitionDuration { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -40,7 +40,7 @@ public CurrentSceneTransitionDurationChangedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public CurrentSceneTransitionDurationChangedPayload(int transitionDuration) + public CurrentSceneTransitionDurationChangedPayload(long transitionDuration) { this.TransitionDuration = transitionDuration; } diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs index 406822e..c171dfd 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs @@ -29,7 +29,7 @@ public sealed partial record SceneItemCreatedPayload /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Index position of the item @@ -75,7 +75,7 @@ public SceneItemCreatedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SceneItemCreatedPayload(string sceneName, string sceneUuid, string sourceName, string sourceUuid, int sceneItemId, int sceneItemIndex) + public SceneItemCreatedPayload(string sceneName, string sceneUuid, string sourceName, string sourceUuid, long sceneItemId, int sceneItemIndex) { this.SceneName = sceneName; this.SceneUuid = sceneUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs index 99acf10..9dc05e7 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs @@ -36,7 +36,7 @@ public sealed partial record SceneItemEnableStateChangedPayload /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -61,7 +61,7 @@ public SceneItemEnableStateChangedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SceneItemEnableStateChangedPayload(string sceneName, string sceneUuid, int sceneItemId, bool sceneItemEnabled) + public SceneItemEnableStateChangedPayload(string sceneName, string sceneUuid, long sceneItemId, bool sceneItemEnabled) { this.SceneName = sceneName; this.SceneUuid = sceneUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs index b13fc77..e343624 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs @@ -29,7 +29,7 @@ public sealed partial record SceneItemLockStateChangedPayload /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Whether the scene item is locked @@ -61,7 +61,7 @@ public SceneItemLockStateChangedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SceneItemLockStateChangedPayload(string sceneName, string sceneUuid, int sceneItemId, bool sceneItemLocked) + public SceneItemLockStateChangedPayload(string sceneName, string sceneUuid, long sceneItemId, bool sceneItemLocked) { this.SceneName = sceneName; this.SceneUuid = sceneUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs index 5d69c8b..8f128c1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs @@ -31,7 +31,7 @@ public sealed partial record SceneItemRemovedPayload /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item was removed from @@ -70,7 +70,7 @@ public SceneItemRemovedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SceneItemRemovedPayload(string sceneName, string sceneUuid, string sourceName, string sourceUuid, int sceneItemId) + public SceneItemRemovedPayload(string sceneName, string sceneUuid, string sourceName, string sourceUuid, long sceneItemId) { this.SceneName = sceneName; this.SceneUuid = sceneUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs index defb464..f5c93a5 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs @@ -29,7 +29,7 @@ public sealed partial record SceneItemSelectedPayload /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -54,7 +54,7 @@ public SceneItemSelectedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SceneItemSelectedPayload(string sceneName, string sceneUuid, int sceneItemId) + public SceneItemSelectedPayload(string sceneName, string sceneUuid, long sceneItemId) { this.SceneName = sceneName; this.SceneUuid = sceneUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs index 636a70d..8d1ee0a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs @@ -29,7 +29,7 @@ public sealed partial record SceneItemTransformChangedPayload /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// New transform/crop info of the scene item @@ -61,7 +61,7 @@ public SceneItemTransformChangedPayload() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SceneItemTransformChangedPayload(string sceneName, string sceneUuid, int sceneItemId, ObsWebSocket.Core.Protocol.Common.SceneItemTransformStub sceneItemTransform) + public SceneItemTransformChangedPayload(string sceneName, string sceneUuid, long sceneItemId, ObsWebSocket.Core.Protocol.Common.SceneItemTransformStub sceneItemTransform) { this.SceneName = sceneName; this.SceneUuid = sceneUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs index a9f3e2e..fe51c9a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs @@ -68,7 +68,7 @@ public sealed partial record DuplicateSceneItemRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -101,7 +101,7 @@ public DuplicateSceneItemRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public DuplicateSceneItemRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null, string? destinationSceneName = null, string? destinationSceneUuid = null) + public DuplicateSceneItemRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null, string? destinationSceneName = null, string? destinationSceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs index bc37f79..ee54579 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs @@ -56,7 +56,7 @@ public sealed partial record GetSceneItemBlendModeRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -89,7 +89,7 @@ public GetSceneItemBlendModeRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetSceneItemBlendModeRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public GetSceneItemBlendModeRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs index 7308cf6..9f10718 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs @@ -46,7 +46,7 @@ public sealed partial record GetSceneItemEnabledRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -79,7 +79,7 @@ public GetSceneItemEnabledRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetSceneItemEnabledRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public GetSceneItemEnabledRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs index c4b96b7..b3194da 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs @@ -48,7 +48,7 @@ public sealed partial record GetSceneItemIndexRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -81,7 +81,7 @@ public GetSceneItemIndexRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetSceneItemIndexRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public GetSceneItemIndexRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs index 6afb955..2436b6b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs @@ -46,7 +46,7 @@ public sealed partial record GetSceneItemLockedRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -79,7 +79,7 @@ public GetSceneItemLockedRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetSceneItemLockedRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public GetSceneItemLockedRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs index 34415b4..9416886 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs @@ -44,7 +44,7 @@ public sealed partial record GetSceneItemSourceRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -77,7 +77,7 @@ public GetSceneItemSourceRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetSceneItemSourceRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public GetSceneItemSourceRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs index af05154..8ef3d56 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs @@ -46,7 +46,7 @@ public sealed partial record GetSceneItemTransformRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -79,7 +79,7 @@ public GetSceneItemTransformRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetSceneItemTransformRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public GetSceneItemTransformRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs index 7a3b6ae..938fc95 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs @@ -46,7 +46,7 @@ public sealed partial record RemoveSceneItemRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -79,7 +79,7 @@ public RemoveSceneItemRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public RemoveSceneItemRequestData(int sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public RemoveSceneItemRequestData(long sceneItemId, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs index 6236fe9..c5aca9b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs @@ -33,7 +33,7 @@ public sealed partial record SetCurrentSceneTransitionDurationRequestData /// [JsonPropertyName("transitionDuration")] [Key("transitionDuration")] - public required int TransitionDuration { get; init; } + public required long TransitionDuration { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -44,7 +44,7 @@ public SetCurrentSceneTransitionDurationRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetCurrentSceneTransitionDurationRequestData(int transitionDuration) + public SetCurrentSceneTransitionDurationRequestData(long transitionDuration) { this.TransitionDuration = transitionDuration; } diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs index 2f83328..6c62eae 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs @@ -56,7 +56,7 @@ public sealed partial record SetSceneItemBlendModeRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -89,7 +89,7 @@ public SetSceneItemBlendModeRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetSceneItemBlendModeRequestData(int sceneItemId, string sceneItemBlendMode, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public SetSceneItemBlendModeRequestData(long sceneItemId, string sceneItemBlendMode, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs index 4f488fc..ce9870d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs @@ -56,7 +56,7 @@ public sealed partial record SetSceneItemEnabledRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Name of the scene the item is in @@ -89,7 +89,7 @@ public SetSceneItemEnabledRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetSceneItemEnabledRequestData(int sceneItemId, bool sceneItemEnabled, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public SetSceneItemEnabledRequestData(long sceneItemId, bool sceneItemEnabled, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs index 9b52d13..8505b1b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs @@ -46,7 +46,7 @@ public sealed partial record SetSceneItemIndexRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// New index position of the scene item @@ -90,7 +90,7 @@ public SetSceneItemIndexRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetSceneItemIndexRequestData(int sceneItemId, int sceneItemIndex, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public SetSceneItemIndexRequestData(long sceneItemId, int sceneItemIndex, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs index 540613b..7c96467 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs @@ -46,7 +46,7 @@ public sealed partial record SetSceneItemLockedRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// New lock state of the scene item @@ -89,7 +89,7 @@ public SetSceneItemLockedRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetSceneItemLockedRequestData(int sceneItemId, bool sceneItemLocked, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public SetSceneItemLockedRequestData(long sceneItemId, bool sceneItemLocked, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs index 431ab4d..e3c662a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs @@ -44,7 +44,7 @@ public sealed partial record SetSceneItemTransformRequestData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// /// Object containing scene item transform info to update @@ -87,7 +87,7 @@ public SetSceneItemTransformRequestData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public SetSceneItemTransformRequestData(int sceneItemId, ObsWebSocket.Core.Protocol.Common.SceneItemTransformPatchStub? sceneItemTransform, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) + public SetSceneItemTransformRequestData(long sceneItemId, ObsWebSocket.Core.Protocol.Common.SceneItemTransformPatchStub? sceneItemTransform, string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs index ac2de92..5a14346 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs @@ -67,7 +67,7 @@ public sealed partial record SetSceneSceneTransitionOverrideRequestData /// [JsonPropertyName("transitionDuration")] [Key("transitionDuration")] - public int? TransitionDuration { get; init; } + public long? TransitionDuration { get; init; } /// /// Name of the scene transition to use as override. Specify `null` to remove @@ -88,7 +88,7 @@ public SetSceneSceneTransitionOverrideRequestData() { } /// Initializes a new instance with all properties specified. /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// - public SetSceneSceneTransitionOverrideRequestData(string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null, string? transitionName = null, int? transitionDuration = null) + public SetSceneSceneTransitionOverrideRequestData(string? canvasUuid = null, string? sceneName = null, string? sceneUuid = null, string? transitionName = null, long? transitionDuration = null) { this.CanvasUuid = canvasUuid; this.SceneName = sceneName; diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs index feaba6e..150deb4 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs @@ -36,7 +36,7 @@ public sealed partial record CreateInputResponseData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -47,7 +47,7 @@ public CreateInputResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public CreateInputResponseData(string inputUuid, int sceneItemId) + public CreateInputResponseData(string inputUuid, long sceneItemId) { this.InputUuid = inputUuid; this.SceneItemId = sceneItemId; diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs index d81b54c..275bd4b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs @@ -31,7 +31,7 @@ public sealed partial record CreateSceneItemResponseData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -42,7 +42,7 @@ public CreateSceneItemResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public CreateSceneItemResponseData(int sceneItemId) + public CreateSceneItemResponseData(long sceneItemId) { this.SceneItemId = sceneItemId; } diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs index cc4c27a..22cb4cd 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs @@ -31,7 +31,7 @@ public sealed partial record DuplicateSceneItemResponseData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -42,7 +42,7 @@ public DuplicateSceneItemResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public DuplicateSceneItemResponseData(int sceneItemId) + public DuplicateSceneItemResponseData(long sceneItemId) { this.SceneItemId = sceneItemId; } diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs index 2e1ebc2..0edd818 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs @@ -36,7 +36,7 @@ public sealed partial record GetCurrentSceneTransitionResponseData /// [JsonPropertyName("transitionDuration")] [Key("transitionDuration")] - public int? TransitionDuration { get; init; } + public long? TransitionDuration { get; init; } /// /// Whether the transition uses a fixed (unconfigurable) duration @@ -82,7 +82,7 @@ public GetCurrentSceneTransitionResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetCurrentSceneTransitionResponseData(string transitionName, string transitionUuid, string transitionKind, bool transitionFixed, bool transitionConfigurable, int? transitionDuration = null, System.Text.Json.JsonElement? transitionSettings = default) + public GetCurrentSceneTransitionResponseData(string transitionName, string transitionUuid, string transitionKind, bool transitionFixed, bool transitionConfigurable, long? transitionDuration = null, System.Text.Json.JsonElement? transitionSettings = default) { this.TransitionName = transitionName; this.TransitionUuid = transitionUuid; diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs index bb89ae2..ff0ca93 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs @@ -64,7 +64,7 @@ public sealed partial record GetOutputStatusResponseData /// [JsonPropertyName("outputSkippedFrames")] [Key("outputSkippedFrames")] - public required int OutputSkippedFrames { get; init; } + public required long OutputSkippedFrames { get; init; } /// /// Current formatted timecode string for the output @@ -78,7 +78,7 @@ public sealed partial record GetOutputStatusResponseData /// [JsonPropertyName("outputTotalFrames")] [Key("outputTotalFrames")] - public required int OutputTotalFrames { get; init; } + public required long OutputTotalFrames { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -89,7 +89,7 @@ public GetOutputStatusResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetOutputStatusResponseData(bool outputActive, bool outputReconnecting, string outputTimecode, long outputDuration, double outputCongestion, long outputBytes, int outputSkippedFrames, int outputTotalFrames) + public GetOutputStatusResponseData(bool outputActive, bool outputReconnecting, string outputTimecode, long outputDuration, double outputCongestion, long outputBytes, long outputSkippedFrames, long outputTotalFrames) { this.OutputActive = outputActive; this.OutputReconnecting = outputReconnecting; diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs index 4fbba5a..dbf827b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs @@ -31,7 +31,7 @@ public sealed partial record GetSceneItemIdResponseData /// [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -42,7 +42,7 @@ public GetSceneItemIdResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetSceneItemIdResponseData(int sceneItemId) + public GetSceneItemIdResponseData(long sceneItemId) { this.SceneItemId = sceneItemId; } diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs index c1cece7..2dde8b6 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs @@ -31,7 +31,7 @@ public sealed partial record GetSceneSceneTransitionOverrideResponseData /// [JsonPropertyName("transitionDuration")] [Key("transitionDuration")] - public int? TransitionDuration { get; init; } + public long? TransitionDuration { get; init; } /// /// Name of the overridden scene transition, else `null` @@ -48,7 +48,7 @@ public GetSceneSceneTransitionOverrideResponseData() { } /// Initializes a new instance with all properties specified. /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// - public GetSceneSceneTransitionOverrideResponseData(string? transitionName = null, int? transitionDuration = null) + public GetSceneSceneTransitionOverrideResponseData(string? transitionName = null, long? transitionDuration = null) { this.TransitionName = transitionName; this.TransitionDuration = transitionDuration; diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs index 07d2863..0b4fa0d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs @@ -64,42 +64,42 @@ public sealed partial record GetStatsResponseData /// [JsonPropertyName("outputSkippedFrames")] [Key("outputSkippedFrames")] - public required int OutputSkippedFrames { get; init; } + public required long OutputSkippedFrames { get; init; } /// /// Total number of frames outputted by the output thread /// [JsonPropertyName("outputTotalFrames")] [Key("outputTotalFrames")] - public required int OutputTotalFrames { get; init; } + public required long OutputTotalFrames { get; init; } /// /// Number of frames skipped by OBS in the render thread /// [JsonPropertyName("renderSkippedFrames")] [Key("renderSkippedFrames")] - public required int RenderSkippedFrames { get; init; } + public required long RenderSkippedFrames { get; init; } /// /// Total number of frames outputted by the render thread /// [JsonPropertyName("renderTotalFrames")] [Key("renderTotalFrames")] - public required int RenderTotalFrames { get; init; } + public required long RenderTotalFrames { get; init; } /// /// Total number of messages received by obs-websocket from the client /// [JsonPropertyName("webSocketSessionIncomingMessages")] [Key("webSocketSessionIncomingMessages")] - public required int WebSocketSessionIncomingMessages { get; init; } + public required long WebSocketSessionIncomingMessages { get; init; } /// /// Total number of messages sent by obs-websocket to the client /// [JsonPropertyName("webSocketSessionOutgoingMessages")] [Key("webSocketSessionOutgoingMessages")] - public required int WebSocketSessionOutgoingMessages { get; init; } + public required long WebSocketSessionOutgoingMessages { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -110,7 +110,7 @@ public GetStatsResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetStatsResponseData(double cpuUsage, double memoryUsage, double availableDiskSpace, double activeFps, double averageFrameRenderTime, int renderSkippedFrames, int renderTotalFrames, int outputSkippedFrames, int outputTotalFrames, int webSocketSessionIncomingMessages, int webSocketSessionOutgoingMessages) + public GetStatsResponseData(double cpuUsage, double memoryUsage, double availableDiskSpace, double activeFps, double averageFrameRenderTime, long renderSkippedFrames, long renderTotalFrames, long outputSkippedFrames, long outputTotalFrames, long webSocketSessionIncomingMessages, long webSocketSessionOutgoingMessages) { this.CpuUsage = cpuUsage; this.MemoryUsage = memoryUsage; diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs index 47912f4..221d672 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs @@ -64,7 +64,7 @@ public sealed partial record GetStreamStatusResponseData /// [JsonPropertyName("outputSkippedFrames")] [Key("outputSkippedFrames")] - public required int OutputSkippedFrames { get; init; } + public required long OutputSkippedFrames { get; init; } /// /// Current formatted timecode string for the output @@ -78,7 +78,7 @@ public sealed partial record GetStreamStatusResponseData /// [JsonPropertyName("outputTotalFrames")] [Key("outputTotalFrames")] - public required int OutputTotalFrames { get; init; } + public required long OutputTotalFrames { get; init; } /// Initializes a new instance for deserialization via . [JsonConstructor] @@ -89,7 +89,7 @@ public GetStreamStatusResponseData() { } /// Parameters are ordered with required properties first, then optional properties (with defaults). Follows protocol definition order where possible. /// [System.Diagnostics.CodeAnalysis.SetsRequiredMembers] - public GetStreamStatusResponseData(bool outputActive, bool outputReconnecting, string outputTimecode, long outputDuration, double outputCongestion, long outputBytes, int outputSkippedFrames, int outputTotalFrames) + public GetStreamStatusResponseData(bool outputActive, bool outputReconnecting, string outputTimecode, long outputDuration, double outputCongestion, long outputBytes, long outputSkippedFrames, long outputTotalFrames) { this.OutputActive = outputActive; this.OutputReconnecting = outputReconnecting; diff --git a/ObsWebSocket.Core/Groups/SceneItemsGroup.cs b/ObsWebSocket.Core/Groups/SceneItemsGroup.cs index 9c682dc..9accdac 100644 --- a/ObsWebSocket.Core/Groups/SceneItemsGroup.cs +++ b/ObsWebSocket.Core/Groups/SceneItemsGroup.cs @@ -31,7 +31,7 @@ public readonly partial struct SceneItemsGroup /// Thrown if the client is not connected. public async Task SetSceneItemEnabledAsync( string sceneName, - int sceneItemId, + long sceneItemId, bool? isEnabled = null, // If null, toggles; otherwise sets to the specified state CancellationToken cancellationToken = default ) @@ -99,7 +99,7 @@ public async Task SetSceneItemEnabledAsync( ArgumentException.ThrowIfNullOrEmpty(sourceName); client.EnsureConnected(); - int? sceneItemId = await client + long? sceneItemId = await client .SceneItems.FindSceneItemIdAsync(sceneName, sourceName, cancellationToken) .ConfigureAwait(false); @@ -126,7 +126,7 @@ public async Task SetSceneItemEnabledAsync( /// A token to cancel the operation. /// The scene item id, or if the source is not in the scene. /// Thrown if the client is not connected. - public async Task FindSceneItemIdAsync( + public async Task FindSceneItemIdAsync( string sceneName, string sourceName, CancellationToken cancellationToken = default @@ -156,19 +156,4 @@ public async Task SetSceneItemEnabledAsync( // Let other ObsWebSocketExceptions or different exception types propagate } - /// - /// Returns the scene item id for a source within a scene as an , or - /// when the scene does not contain it. - /// - /// The name of the scene to search. - /// The name of the source to locate. - /// A token to cancel the operation. - [Obsolete( - "FindSceneItemIdAsync now returns int?, so this variant is redundant. This forwarder will be removed in a future release." - )] - public Task FindSceneItemIdInt32Async( - string sceneName, - string sourceName, - CancellationToken cancellationToken = default - ) => FindSceneItemIdAsync(sceneName, sourceName, cancellationToken); } diff --git a/ObsWebSocket.Core/Handles/ObsHandles.cs b/ObsWebSocket.Core/Handles/ObsHandles.cs index 45ca96b..1975adb 100644 --- a/ObsWebSocket.Core/Handles/ObsHandles.cs +++ b/ObsWebSocket.Core/Handles/ObsHandles.cs @@ -157,7 +157,7 @@ public static SceneHandle FromUuid(string uuid) /// /// A scene item in this scene, by the numeric id OBS assigned it. /// - public SceneItemHandle Item(int sceneItemId) => SceneItemHandle.For(this, sceneItemId); + public SceneItemHandle Item(long sceneItemId) => SceneItemHandle.For(this, sceneItemId); /// /// A scene item in this scene, by the name of the source it shows. Needs resolving, because @@ -297,7 +297,7 @@ public static SourceHandle FromUuid(string uuid) /// public sealed record SceneItemHandle { - private SceneItemHandle(SceneHandle scene, int sceneItemId) + private SceneItemHandle(SceneHandle scene, long sceneItemId) { Scene = scene; SceneItemId = sceneItemId; @@ -307,10 +307,10 @@ private SceneItemHandle(SceneHandle scene, int sceneItemId) public SceneHandle Scene { get; } /// The numeric id OBS assigned the item. - public int SceneItemId { get; } + public long SceneItemId { get; } /// Builds a handle for an id already known. - public static SceneItemHandle For(SceneHandle scene, int sceneItemId) + public static SceneItemHandle For(SceneHandle scene, long sceneItemId) { ArgumentNullException.ThrowIfNull(scene); ArgumentOutOfRangeException.ThrowIfNegative(sceneItemId); diff --git a/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs b/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs index cc8df4c..397b577 100644 --- a/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs +++ b/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs @@ -25,7 +25,7 @@ await client.Scenes.ResolveAsync(handle, cancellationToken).ConfigureAwait(false /// A scene item in this scene, by the numeric id OBS assigned it. /// The id, which GetSceneItemList or an event reports. - public SceneItemOperations Item(int sceneItemId) => new(client, handle.Item(sceneItemId)); + public SceneItemOperations Item(long sceneItemId) => new(client, handle.Item(sceneItemId)); /// /// A scene item in this scene, by the name of the source it shows. diff --git a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs index a46b78c..f05ccbe 100644 --- a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs +++ b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs @@ -22,9 +22,15 @@ public sealed class SceneStub public required string SceneUuid { get; init; } /// Scene index position. + /// + /// Nullable because OBS sends null, not because the protocol says so. The main scene list + /// numbers its entries, but GetCanvasSceneList enumerates through a callback that has no index + /// to report and writes null in its place. As a non-nullable int that failed the whole + /// response rather than the one field. + /// [JsonPropertyName("sceneIndex")] [Key("sceneIndex")] - public required int SceneIndex { get; init; } + public int? SceneIndex { get; init; } /// Captures any extra fields not explicitly defined in the stub. [IgnoreMember] @@ -50,7 +56,7 @@ public sealed class SceneItemOrderStub /// Numeric ID of the scene item. [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// Index of the scene item, counted from the bottom of the list. [JsonPropertyName("sceneItemIndex")] @@ -306,11 +312,17 @@ public sealed class SceneItemTransformStub public required double SourceHeight { get; init; } /// - /// Alignment value. + /// Alignment of the scene item, as an OBS_ALIGN_* bit mask. /// + /// + /// A mask, not a count, and OBS treats it as the full width of one: the field is + /// uint32_t in obs_transform_info, and obs-websocket validates writes to + /// 0 .. uint32_t max rather than to the flags it defines. A value past + /// is accepted on the way in and handed back on the way out. + /// [JsonPropertyName("alignment")] [Key("alignment")] - public required int Alignment { get; init; } + public required long Alignment { get; init; } /// /// Bounds type value. @@ -320,11 +332,12 @@ public sealed class SceneItemTransformStub public required string BoundsType { get; init; } /// - /// Bounds alignment value. + /// Alignment of the bounding box, as an OBS_ALIGN_* bit mask. See + /// for why this is 64 bits wide. /// [JsonPropertyName("boundsAlignment")] [Key("boundsAlignment")] - public required int BoundsAlignment { get; init; } + public required long BoundsAlignment { get; init; } /// /// Bounds width value. @@ -454,11 +467,11 @@ public sealed class SceneItemTransformPatchStub public double? SourceHeight { get; init; } /// - /// Alignment value. + /// Alignment of the scene item, as an OBS_ALIGN_* bit mask. /// [JsonPropertyName("alignment")] [Key("alignment")] - public int? Alignment { get; init; } + public long? Alignment { get; init; } /// /// Bounds type value. @@ -468,11 +481,11 @@ public sealed class SceneItemTransformPatchStub public string? BoundsType { get; init; } /// - /// Bounds alignment value. + /// Alignment of the bounding box, as an OBS_ALIGN_* bit mask. /// [JsonPropertyName("boundsAlignment")] [Key("boundsAlignment")] - public int? BoundsAlignment { get; init; } + public long? BoundsAlignment { get; init; } /// /// Bounds width value. @@ -536,7 +549,7 @@ public sealed class SceneItemStub /// Scene item ID. [JsonPropertyName("sceneItemId")] [Key("sceneItemId")] - public required int SceneItemId { get; init; } + public required long SceneItemId { get; init; } /// Scene item index position. [JsonPropertyName("sceneItemIndex")] @@ -726,15 +739,28 @@ public sealed class OutputStub [Key("outputActive")] public required bool OutputActive { get; init; } - /// Output width. + /// + /// Output width in pixels, which an output that has never started may report as garbage. + /// + /// + /// Wider than a pixel count needs to be, because the wire value is wider. OBS fills this from + /// obs_output_get_width, which returns uint32_t and is passed through unclamped, + /// and an idle output can report a value above — a live OBS 32.2.2 + /// sent 2586032160 for an inactive virtual camera. As an that made the whole + /// GetOutputList response unreadable, intermittently, and only for whoever happened to + /// have such an output installed. Treat a value over 4096 as "not meaningful", not as a size. + /// [JsonPropertyName("outputWidth")] [Key("outputWidth")] - public required int OutputWidth { get; init; } + public required long OutputWidth { get; init; } - /// Output height. + /// + /// Output height in pixels, which an output that has never started may report as garbage. See + /// for why this is 64 bits wide. + /// [JsonPropertyName("outputHeight")] [Key("outputHeight")] - public required int OutputHeight { get; init; } + public required long OutputHeight { get; init; } /// Output settings. [JsonPropertyName("outputSettings")] diff --git a/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs index 0bfa367..166d12c 100644 --- a/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs @@ -441,7 +441,7 @@ public async Task GetSceneItemTransform_ReturnsTransformStub() idResponse, $"Could not get SceneItemId for '{s_testOptions.TestInputName}' in scene '{s_testOptions.TestSceneName}'. Ensure it exists." ); - int sceneItemId = idResponse.SceneItemId; + long sceneItemId = idResponse.SceneItemId; // Get the transform GetSceneItemTransformResponseData? transformResponse = diff --git a/ObsWebSocket.Tests/StubArrayTests.cs b/ObsWebSocket.Tests/StubArrayTests.cs index 7092e6e..ca335bd 100644 --- a/ObsWebSocket.Tests/StubArrayTests.cs +++ b/ObsWebSocket.Tests/StubArrayTests.cs @@ -254,6 +254,179 @@ public void MeterItem_ReadAsInputStub_FailsOnTheKindFieldsItNeverSends() StringAssert.Contains(ex.InnerException!.Message, "inputKind"); } + + /// + /// OBS copies the output dimensions straight out of obs_output_get_width and + /// obs_output_get_height, which return uint32_t and are not clamped on the way + /// out. An output that has never started can report a value past — + /// a live OBS 32.2.2 sent 2586032160 for an idle virtual camera — and as an int that + /// took the whole GetOutputList response down, not just the one field. + /// + [TestMethod] + public void OutputList_HeightAboveInt32Max_ReadsOverJson() + { + JsonElement payload = JsonDocument + .Parse( + """ + { + "outputs": [ + { + "outputName": "virtualcam_output", + "outputKind": "virtualcam_output", + "outputActive": false, + "outputWidth": 0, + "outputHeight": 2586032160, + "outputFlags": { "OBS_OUTPUT_VIDEO": true } + } + ] + } + """ + ) + .RootElement.Clone(); + + GetOutputListResponseData? read = CreateJsonSerializer() + .DeserializePayload(payload); + + Assert.IsNotNull(read); + Assert.AreEqual(1, read.Outputs.Count); + Assert.AreEqual(2586032160L, read.Outputs[0].OutputHeight); + } + + [TestMethod] + public void OutputList_HeightAboveInt32Max_RoundTripsOverMsgPack() + { + GetOutputListResponseData original = new() + { + Outputs = + [ + new OutputStub + { + OutputName = "virtualcam_output", + OutputKind = "virtualcam_output", + OutputActive = false, + OutputWidth = 0, + OutputHeight = 2586032160L, + }, + ], + }; + + byte[] packed = MessagePackSerializer.Serialize( + original, + MsgPackMessageSerializer.s_msgPackOptions + ); + GetOutputListResponseData read = MessagePackSerializer.Deserialize( + packed, + MsgPackMessageSerializer.s_msgPackOptions + ); + + Assert.AreEqual(2586032160L, read.Outputs[0].OutputHeight); + } + + /// + /// The same defect in the counters. The frame counts come from uint32_t and the session + /// message counts from uint64_t, none of them clamped, and a monotonic frame counter + /// passes after roughly 414 days at 60fps. + /// + [TestMethod] + public void Stats_CountersAboveInt32Max_ReadOverJson() + { + JsonElement payload = JsonDocument + .Parse( + """ + { + "cpuUsage": 1.5, + "memoryUsage": 512.0, + "availableDiskSpace": 1024.0, + "activeFps": 60.0, + "averageFrameRenderTime": 1.2, + "renderSkippedFrames": 4294967295, + "renderTotalFrames": 3000000000, + "outputSkippedFrames": 2147483648, + "outputTotalFrames": 4000000000, + "webSocketSessionIncomingMessages": 5000000000, + "webSocketSessionOutgoingMessages": 6000000000 + } + """ + ) + .RootElement.Clone(); + + GetStatsResponseData? read = CreateJsonSerializer() + .DeserializePayload(payload); + + Assert.IsNotNull(read); + Assert.AreEqual(4294967295L, read.RenderSkippedFrames); + Assert.AreEqual(3000000000L, read.RenderTotalFrames); + Assert.AreEqual(6000000000L, read.WebSocketSessionOutgoingMessages); + } + + + /// + /// A canvas-scoped scene list has no index to report, so OBS sends sceneIndex as null + /// rather than omitting it. As a non-nullable member that failed the whole response. + /// + [TestMethod] + public void SceneList_NullSceneIndex_ReadsOverJson() + { + JsonElement payload = JsonDocument + .Parse( + """ + { + "scenes": [ + { + "sceneName": "Intro", + "sceneUuid": "0e57ad4c-2b2d-4f5b-9d05-3f4b0f4f1f10", + "sceneIndex": null + } + ] + } + """ + ) + .RootElement.Clone(); + + GetSceneListResponseData? read = CreateJsonSerializer() + .DeserializePayload(payload); + + Assert.IsNotNull(read); + Assert.AreEqual(1, read.Scenes.Count); + Assert.IsNull(read.Scenes[0].SceneIndex); + } + + /// + /// The alignment fields are uint32_t masks in obs_transform_info, and + /// obs-websocket validates writes to the whole unsigned range rather than to the flags it + /// defines, so a value past reaches the client. + /// + [TestMethod] + public void SceneItemTransform_AlignmentAboveInt32Max_ReadsOverJson() + { + JsonElement payload = JsonDocument + .Parse( + """ + { + "sceneItemTransform": { + "positionX": 0, "positionY": 0, "rotation": 0, + "scaleX": 1, "scaleY": 1, "width": 100, "height": 100, + "sourceWidth": 100, "sourceHeight": 100, + "alignment": 4294967295, + "boundsType": "OBS_BOUNDS_NONE", + "boundsAlignment": 3000000000, + "boundsWidth": 0, "boundsHeight": 0, + "cropLeft": 0, "cropTop": 0, "cropRight": 0, "cropBottom": 0, + "cropToBounds": false + } + } + """ + ) + .RootElement.Clone(); + + GetSceneItemTransformResponseData? read = CreateJsonSerializer() + .DeserializePayload(payload); + + Assert.IsNotNull(read); + Assert.AreEqual(4294967295L, read.SceneItemTransform.Alignment); + Assert.AreEqual(3000000000L, read.SceneItemTransform.BoundsAlignment); + } + /// /// The formatter that made an unmapped array readable at all. Nothing generated uses it now /// that every array is mapped, but it is what stands between a future unmapped array and a From 7bc9be86b9ec44030ceea194587889c0f8562025 Mon Sep 17 00:00:00 2001 From: Agash Date: Mon, 31 Aug 2026 15:47:29 +0200 Subject: [PATCH 07/11] docs: document the three levels, and cut the padding The handle API had no entry in the README at all, so nothing pointed at it. Adds a table up front for choosing between handles, the category groups and CallAsync, then a section on handles themselves. Trims what was explaining implementation rather than behaviour: the options-validation note, the non-null response note, and the parallel batch section, which spent ninety lines on an upstream labelling bug. --- ObsWebSocket.Tests/ReadmeCompileCheck.cs | 55 ++++- README.md | 271 ++++++++++++++--------- 2 files changed, 224 insertions(+), 102 deletions(-) diff --git a/ObsWebSocket.Tests/ReadmeCompileCheck.cs b/ObsWebSocket.Tests/ReadmeCompileCheck.cs index bec11b9..5a4967b 100644 --- a/ObsWebSocket.Tests/ReadmeCompileCheck.cs +++ b/ObsWebSocket.Tests/ReadmeCompileCheck.cs @@ -299,7 +299,7 @@ CancellationToken ct internal static async Task NumbersAsync(ObsWebSocketClient client, CancellationToken ct) { - int id = + long id = await client.SceneItems.FindSceneItemIdAsync("Intro", "Logo", ct) ?? throw new InvalidOperationException(); await client.SceneItems.SetSceneItemIndexAsync( @@ -415,6 +415,59 @@ internal static async Task LowLevelAsync(ObsWebSocketClient client, Cancellation _ = $"{v?.ObsVersion} {raw}"; } + internal static async Task HandlesAsync( + ObsWebSocketClient client, + Guid sceneGuid, + CancellationToken ct + ) + { + 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); + } + + internal static async Task HandleResolveAsync(ObsWebSocketClient client, CancellationToken ct) + { + SceneOperations intro = await client.Scene("Intro").ResolveAsync(ct); + _ = intro.Handle.IsResolved; + } + + internal static void FreeSceneHandle(ObsWebSocketClient client) + { + client.Scenes.CurrentProgramSceneChanged += async (_, e) => + await client.Scene(e.EventData.Scene).GetItemListAsync(); + } + + internal static async Task FreeResponseHandleAsync( + ObsWebSocketClient client, + CancellationToken ct + ) + { + CreateSceneResponseData created = await client.Scenes.CreateSceneAsync(new("Intro"), ct); + await client.Scene(created.Scene).SetCurrentProgramAsync(ct); + } + + internal static async Task SceneItemHandlesAsync( + ObsWebSocketClient client, + CancellationToken ct + ) + { + 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); + } + + internal static async Task CanvasHandlesAsync(ObsWebSocketClient client, CancellationToken ct) + { + CanvasHandle vertical = await client.Canvases.ResolveAsync("Vertical", ct); + await client.Scene(vertical.Scene("Intro")).GetItemListAsync(ct); + } + internal static async Task GroupedSurfaceAsync(ObsWebSocketClient client, CancellationToken ct) { await client.Scenes.GetSceneListAsync(new(), ct); diff --git a/README.md b/README.md index 1b35124..09571fc 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,29 @@ public sealed class Worker(ObsWebSocketClient client) : BackgroundService } ``` -## Everything is grouped by category +## Three levels + +The client exposes the protocol at three levels. Most code lives in the middle one and reaches for +the others where they pay. + +| Level | Looks like | Reach for it when | +|---|---|---| +| [Handles](#handles) | `client.Input("Mic").SetMuteAsync(true, ct)` | Several calls concern one scene, input, source, item or filter; identity has to survive a rename; you already hold a uuid | +| [Category groups](#the-category-groups) | `client.Inputs.SetInputMuteAsync(new("Mic", true), ct)` | Anything. One method per protocol request, plus the helpers | +| [Raw](#dropping-to-the-low-level) | `client.CallAsync("SetInputMute", data, ct)` | A request this build does not model: a newer OBS, a vendor plugin | + +Each level forwards to the one below it, so mixing them costs nothing and no level hides anything +the one below can reach. + +Handles cover the 66 requests that act on one named thing. The rest — `GetVersion`, `GetStats`, +record and stream control, profiles, video settings — are not about a particular thing, so they +exist only on their group. The hand-written helpers (`SetInputVolumeDbAsync`, +`SwitchProgramSceneAndWaitAsync`, the typed settings pairs) live on the group too. + +## The category groups The client mirrors the categories the OBS protocol defines. Requests, event streams and the -conveniences this library adds all sit in the group their category owns, so there is one way to -reach anything: +helpers this library adds all sit in the group their category owns: ```csharp await client.Scenes.GetSceneListAsync(new(), ct); // generated request @@ -90,12 +108,111 @@ protocol definition, so a refresh that recategorises a request moves it here too `WaitForEventAsync` and `CallBatchAsync` stay directly on the client, since neither belongs to one category. +## Handles + +Every OBS request that acts on something takes its identity as two optional fields, a name and a +uuid, and resolves them in a fixed order: a uuid wins outright, a name is read only when no uuid was +sent, the canvas is consulted only on the name path, and neither field present is +`MissingRequestField`. So `new SetCurrentProgramSceneRequestData()` compiles and fails at runtime, +and passing both silently ignores the name. + +A handle makes that choice once. A string is a name, a `Guid` is a uuid: + +```csharp +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 every request +the protocol defines about that kind of thing, named without the part the handle already says: +`SetSceneItemEnabled` is `SetEnabledAsync` on a scene item, `GetInputMute` is `GetMuteAsync` on an +input. The protocol name stays in the XML docs and on the category group. + +Handles hold identity, nothing else. They cache no state and are safe to keep for the life of an +application. + +### Resolving a name to a uuid + +A name handle is fine when you just typed the name. A uuid handle survives a rename, and is the only +form OBS will accept once it drops names, which the maintainers have said is the plan. + +Resolving costs a round trip, so it is explicit: + +```csharp +SceneOperations intro = await client.Scene("Intro").ResolveAsync(ct); +// intro.Handle.IsResolved is true; a rename in OBS can no longer move it +``` + +The protocol has no narrow lookup — nothing answers "what is the uuid of the scene called X" — so +this is `GetSceneList` and a scan. The list is not wasted: on a miss you get the names that do +exist, where OBS itself can only answer `ResourceNotFound`. + +``` +ObsWebSocketResourceNotFoundException: No scene named 'Intor'. Available: 'Intro', 'Gameplay', 'BRB'. +``` + +### Handles that cost nothing + +An event already says which scene it concerns, by uuid. Reading the name back off it and addressing +by name again is the round trip and the rename race that the uuid was there to avoid: + +```csharp +client.Scenes.CurrentProgramSceneChanged += async (_, e) => + await client.Scene(e.EventData.Scene).GetItemListAsync(); // already resolved +``` + +Forty-seven events and responses carry one, including the creation requests, which answer with the +uuid of what they just made: + +```csharp +CreateSceneResponseData created = await client.Scenes.CreateSceneAsync(new("Intro"), ct); +await client.Scene(created.Scene).SetCurrentProgramAsync(ct); // no lookup +``` + +### Scene items + +Scene items are the one case where a lookup is not a convenience: OBS addresses them by a number +that only `GetSceneItemId` reports. A scene item known by its source name is therefore a different +type from one that can be acted on, and the missing lookup is a compile error rather than a runtime +one: + +```csharp +SceneItemOperations logo = await client.Scene("Intro").ItemAsync("Logo", cancellationToken: ct); +await logo.SetEnabledAsync(false, ct); +await logo.Scene.GetItemListAsync(ct); // navigate back up + +await client.Scene("Intro").Item(3).SetIndexAsync(0, ct); // an id needs no lookup +``` + +`Item(int)` and `Filter(string)` never send anything, since an id and a filter name are already the +whole identity. + +### Canvases + +A canvas has no name in the protocol: every canvas-scoped request takes a uuid, and `canvasName` +appears only in `GetCanvasList`. `CanvasHandle` is the one handle whose name form cannot be sent +anywhere before it is resolved: + +```csharp +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` rather than a null check at +every call site. A canvas only scopes a *name*: OBS ignores `canvasUuid` beside a uuid, so a resolved +handle drops it. + ## The helper set -Alongside the generated request per protocol request, each group carries conveniences for things -that otherwise take several calls or a lookup. Every typed settings helper has two overloads: an -implicit one for library-registered types, and an explicit one taking a `JsonTypeInfo` for -consumer-provided types. Use the explicit overload to stay AOT-safe. +Alongside the generated request per protocol request, each group carries helpers for things that +otherwise take several calls or a lookup. These are hand-written, so they exist only on the group, +not on a handle. + +Every typed settings helper has two overloads: an implicit one for library-registered types, and an +explicit one taking a `JsonTypeInfo` for consumer-provided types. Use the explicit overload to +stay AOT-safe. **Settings read and write** @@ -182,9 +299,8 @@ client.Scenes.CurrentProgramSceneChanged += (_, e) => Console.WriteLine($"Program scene is now {e.EventData.SceneName}"); ``` -Both work over the same event at once. The group's event is the client's event, so a handler added -through one can be removed through the other; `client.CurrentProgramSceneChanged` remains for the -low-level path, the way `CallAsync` remains alongside the generated requests. +The group's event *is* the client's event, so both work at once and a handler added through one can +be removed through the other. Connection lifecycle events stay on the client, since `Connected`, `Disconnected`, `ConnectionFailed` and `AuthenticationFailure` belong to no protocol category. @@ -326,97 +442,46 @@ batch.Add("SetInputSettings", myJsonElement); ### Running requests in parallel -`RequestBatchExecutionType.Parallel` works, but OBS mislabels what comes back. It collects results -in completion order and labels them from the submission order, so on any one row the -`requestType` and `requestId` belong to a different request than the `requestStatus` and -`responseData` beside them. That happens before the response leaves OBS, so it cannot be corrected -here. See [#16](https://github.com/Agash/ObsWebSocket/issues/16). - -Only the labelling is wrong. `requestStatus` and `responseData` come from the same object, so each -row's status does belong to the payload beside it; it is the `requestType` and `requestId` on that -row that name a different request. `Get` and the indexer therefore throw rather than hand back data -under the wrong reference, and `TryGet` reports `false`. - -Nothing is lost, though, and `Raw` still reaches all of it. `GetData` reads the payload without -consulting the label, so every response is recoverable as a set: - -```csharp -BatchResults results = await client.CallBatchAsync( - batch, executionType: RequestBatchExecutionType.Parallel, haltOnFailure: false, cancellationToken: ct); - -foreach (RequestResponsePayload row in results.Raw) -{ - if (!row.RequestStatus.Result) - { - Console.WriteLine($"one request failed with {row.RequestStatus.Code}"); - continue; // the code is right, the requestType naming it is not - } - - // Correct data, from one of the requests in the batch. Which one is not knowable. - GetSceneItemListResponseData? data = row.GetData(); -} -``` - -That works when every request in the batch returns the **same** type, so it does not matter which -row is which, and when the order is not what you needed. - -A parallel batch of **different** request types is harder, because nothing on a row tells you which -type its payload really is. `GetData` will not invent an answer, though: it checks the payload -against the fields `T` expects and throws `ObsWebSocketSerializationException` when the payload -carries none of them. So you can try each type you expect and let the mismatch tell you. - -That check rejects rather than identifies. Two records that share field names cannot be told apart -this way, and a payload overlapping the target only partly still passes with the rest of the -properties left at their defaults. Where two records are field for field identical, which happens -for five shapes including `GetInputMute` and `ToggleInputMute`, reading one as the other gives the -right values anyway. - -So a heterogeneous parallel batch is possible to unpick but never reliable. Use a serial batch, or -concurrent requests. +`RequestBatchExecutionType.Parallel` works, but OBS mislabels what comes back. It collects results in +completion order and labels them from the submission order, so on any row the `requestType` and +`requestId` name a different request than the `requestStatus` and `responseData` beside them. That +happens inside OBS, so it cannot be corrected here. See +[#16](https://github.com/Agash/ObsWebSocket/issues/16). +Only the labelling is wrong — 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`. Anything that does not depend on which row is which stays exact: ```csharp -ObsBatchBuilder batch = new(); -foreach (string input in inputs) -{ - _ = batch.Inputs.SetInputMute(new(inputName: input, inputMuted: true)); -} - BatchResults results = await client.CallBatchAsync( batch, executionType: RequestBatchExecutionType.Parallel, cancellationToken: ct); -bool everythingWorked = results.AllSucceeded(); // reliable: order does not change the verdict -int failureCount = results.GetFailures().Count(); // reliable count, unreliable names +bool everythingWorked = results.AllSucceeded(); // reliable: order does not change the verdict +int failureCount = results.GetFailures().Count(); // reliable count, unreliable names ``` -So `Parallel` suits a set of writes you want applied as fast as possible, where you only need to -know whether they all took. It does not suit reading anything back. +`results.Raw` still reaches every payload, and `GetData` reads one without consulting the label, +so a batch where every request returns the same type is fully recoverable. A batch of mixed types is +not: `GetData` rejects a payload carrying none of `T`'s fields, but that rejects rather than +identifies, and two records with the same field names cannot be told apart. -When you need results attributed, use concurrent requests rather than a parallel batch. The client -multiplexes on the request id, so anything in flight at once is matched back to its own caller: +So `Parallel` suits a set of writes you want applied as fast as possible and only need a pass/fail +on. When you need answers attributed to requests, send them concurrently instead — the client +multiplexes on the request id: ```csharp Task version = client.General.GetVersionAsync(ct); Task stats = client.General.GetStatsAsync(ct); -Task[] perScene = -[ - .. sceneNames.Select(n => client.SceneItems.GetSceneItemListAsync(new(sceneName: n), ct)), -]; -await Task.WhenAll([version, stats, .. perScene.Cast()]); - -Console.WriteLine(version.Result.ObsVersion); // each result belongs to its own request +await Task.WhenAll(version, stats); ``` -That costs one round trip per request rather than one for the set. Use a serial batch when the round -trip is what you are saving, and concurrent requests when you need the answers attributed. +That costs a round trip per request. Use a serial batch when the round trip is what you are saving. ## Dropping to the low level -Nothing above is a wall. Every generated request is a thin wrapper over the same primitives, and -they stay available for a request this build does not model, an OBS newer than this library, or a -vendor plugin: +Every generated request is a thin wrapper over the same primitives, which stay available for a +request this build does not model, an OBS newer than this library, or a vendor plugin: ```csharp // A request with a reference type response. @@ -458,9 +523,9 @@ Those two are the AOT-safe ways to build a payload. `JsonSerializer.SerializeToE `JsonTypeInfo`, and the `JsonNode` and `JsonObject` routes, all work at runtime but carry `IL2026` and `IL3050`, so they are not options under Native AOT. -The same applies to events and enums: `client.SceneCreated` remains alongside -`client.Scenes.SceneCreated`, and `ToWireValue()` / `FromWireValue()` convert an enum to and from -the protocol string when you are building a payload by hand. +Events and enums have the same escape hatch: `client.SceneCreated` remains alongside +`client.Scenes.SceneCreated`, and `ToWireValue()` / `FromWireValue()` convert an enum to and from the +protocol string when you are building a payload by hand. ## Protocol types @@ -474,13 +539,21 @@ generated as `int` or `long`, from an explicit list in the generator rather than names, so a volume can never be truncated by a naming coincidence: ```csharp -int id = await client.SceneItems.FindSceneItemIdAsync("Intro", "Logo", ct) ?? throw new(...); +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; ``` +The width comes from what fills the field upstream, not from how large the value looks. Where +obs-websocket validates a range — resolutions are 8..4096, indices 0..8192 — `int` is enough. +Where it copies a value straight out of libobs, the C type decides: scene item ids and settings +durations are `int64_t`, frame counters and output dimensions are `uint32_t`, and session message +counts are `uint64_t`, so all of those are `long`. This is not cosmetic. An out-of-range value does +not truncate one field, it fails the whole response — an idle virtual camera reporting an +uninitialised `outputHeight` made every `GetOutputList` unreadable. + **Enums.** Fields carrying a protocol enum are that enum, on both the read and the write side, so there is nothing to convert at the call site: @@ -539,8 +612,6 @@ builder.Services.AddObsWebSocketClient(o => }); ``` -Options are validated when the client is resolved, so a missing or malformed `ServerUri` fails at -startup with the offending option named, rather than on the first connection attempt. ### Multiple OBS instances @@ -596,8 +667,6 @@ catch (ObsWebSocketRequestException ex) when (ex.StatusCode is RequestStatusCode `ObsWebSocketSerializationException` covers payloads that cannot be written or read, and all three derive from `ObsWebSocketException`. -Requests return their response data non-nullable; a successful request that carries no payload -raises `ObsWebSocketException` rather than handing back null. ## Reconnect @@ -644,18 +713,18 @@ identically on either, and the validation suite exercises both. `ObsWebSocket.Example` is a host-based sample with configuration and DI. -- **Interactive mode**: command loop (`help`, `version`, `scene`, `watch`, `media`, `status`, `batch-example`, and more) -- **Transport validation mode**: exercises the surface on JSON and MessagePack, then enters the interactive loop -- **One-shot mode**: `ObsWebSocket.Example run-transport-tests` - -`run-transport-tests` creates its own scene and input, so it does not depend on a particular OBS -layout, and removes them afterwards. It runs the same checks on JSON and on MessagePack, asserting -real values rather than that a call returned: the three settings modes, event streams and their -buffering, `WaitForEventAsync`, the typed batch builder including duplicate request types, partial -failure and truncation, a parallel batch and what survives its mispairing, concurrent requests -keeping their own results, the low level `Add` and `CallAsync` path, typed protocol enums, integer -fields round tripping in both directions, screenshots in memory and on disk, and the scene, preview, -input, mute, volume, media, transition and output helpers. +- **Interactive mode**: a command loop — `help` lists it. `mute`, `list-filters` and `toggle-filter` + go through handles, `set-text` and `get-input-settings` deliberately do not, and `resolve` shows + what resolving buys and costs. +- **Validation mode**: `ObsWebSocket.Example run-transport-tests` runs the same checks on JSON and + on MessagePack against a scene, input and filter it creates and removes itself. + +The validation run asserts real values rather than that a call returned. It covers the three +settings modes, event streams and their buffering, `WaitForEventAsync`, the typed batch builder +including duplicate request types and partial failure, parallel batches, the low-level path, typed +enums, screenshots, and the handles — including that a uuid handle still resolves after a rename and +a name handle does not. It also calls every read request and every safely sendable write request in +the protocol, and fails on any response it cannot deserialize. ## Native AOT From 48bc3b04cef9232f022aa525cd90f418feb9ad3a Mon Sep 17 00:00:00 2001 From: Agash Date: Mon, 31 Aug 2026 15:47:37 +0200 Subject: [PATCH 08/11] test(example): show each level where it earns its place The interactive commands all addressed things by name through the category groups, so the handle API had no demonstration. Moves the ones that gain from a handle: mute, list-filters, toggle-filter, the blend mode in add-browser-source, and watch, which now acts on the uuid the event already carries. Adds resolve, for the round trip and what a miss reports. set-text and get-input-settings stay on the group deliberately, because the typed-settings helpers are hand-written and so have no handle form. That contrast is the point; converting everything would hide the rule. The settings-mode checks now create their browser source and gain filter and remove them again. Discovering an existing input made the result depend on the machine: a fresh OBS reported the modes as failing when all that was missing was a source to try them on. --- ObsWebSocket.Example/Worker.cs | 373 +++++++++++++++++++++++---------- 1 file changed, 267 insertions(+), 106 deletions(-) diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index e4318f9..5f7d9e4 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -307,6 +308,20 @@ await _obsClient.Scenes.GetCurrentProgramSceneAsync( ); return false; + case "resolve": + if (args.Length == 0) + { + UiWarn("Usage: resolve [scene name] [source name in that scene]"); + return false; + } + + await ResolveDemoAsync( + args[0], + args.Length > 1 ? string.Join(" ", args[1..]) : null, + cancellationToken + ); + return false; + case "mute": case "unmute": if (args.Length == 0) @@ -317,11 +332,12 @@ await _obsClient.Scenes.GetCurrentProgramSceneAsync( string inputNameToMute = string.Join(" ", args); _logger.LogInformation("Toggling mute for input: {InputName}", inputNameToMute); - ToggleInputMuteResponseData? muteState = - await _obsClient.Inputs.ToggleInputMuteAsync( - new ToggleInputMuteRequestData(inputNameToMute), - cancellationToken: cancellationToken - ); + + // Handle form. One call about one input, so the identity is said once and the + // method name drops the word the handle already carries. + ToggleInputMuteResponseData? muteState = await _obsClient + .Input(inputNameToMute) + .ToggleMuteAsync(cancellationToken); if (muteState is null) { UiWarn($"Could not toggle mute state for {inputNameToMute}. Does it exist?"); @@ -346,7 +362,7 @@ await _obsClient.Inputs.ToggleInputMuteAsync( try { // First, find the scene item ID within the specified scene - int sceneItemId = await GetSceneItemIdAsync( + long sceneItemId = await GetSceneItemIdAsync( sceneForGetSettings, inputForGetSettings, cancellationToken @@ -393,7 +409,7 @@ await _obsClient.Inputs.GetInputSettingsAsync( try { // Find the scene item ID first (optional but good practice) - int sceneItemId = await GetSceneItemIdAsync( + long sceneItemId = await GetSceneItemIdAsync( sceneForSetText, inputForSetText, cancellationToken @@ -405,7 +421,9 @@ await _obsClient.Inputs.GetInputSettingsAsync( sceneForSetText ); - // Uses SetInputTextAsync helper which serializes TextGdiPlusInputSettings internally. + // Protocol level on purpose. SetInputTextAsync is a hand-written group helper + // that serializes TextGdiPlusInputSettings internally; the handles are + // generated from the protocol, so nothing hand-written appears on them. await _obsClient.Inputs.SetInputTextAsync( inputForSetText, newText, @@ -438,11 +456,12 @@ await _obsClient.Inputs.SetInputTextAsync( } string sourceForFilters = string.Join(" ", args); - GetSourceFilterListResponseData? filterList = - await _obsClient.Filters.GetSourceFilterListAsync( - new GetSourceFilterListRequestData(sourceName: sourceForFilters), - cancellationToken: cancellationToken - ); + + // A filter list belongs to the source, not to the Filters category, and the handle + // says so: client.Source(x).GetFilterListAsync, not Filters.GetSourceFilterList. + GetSourceFilterListResponseData? filterList = await _obsClient + .Source(sourceForFilters) + .GetFilterListAsync(cancellationToken); if (filterList?.Filters is not null && filterList.Filters.Count > 0) { Table table = new() @@ -456,7 +475,7 @@ await _obsClient.Filters.GetSourceFilterListAsync( foreach (Core.Protocol.Common.FilterStub filterElement in filterList.Filters) { string filterIndex = filterElement.FilterIndex.ToString( - System.Globalization.CultureInfo.InvariantCulture + CultureInfo.InvariantCulture ); string filterName = Markup.Escape(filterElement.FilterName ?? "N/A") ?? "N/A"; @@ -489,16 +508,16 @@ await _obsClient.Filters.GetSourceFilterListAsync( string sourceForToggle = args[0]; string filterToToggle = string.Join(" ", args[1..]); - // 1. Get current filter state - GetSourceFilterResponseData? currentFilterState = - await _obsClient.Filters.GetSourceFilterAsync( - new GetSourceFilterRequestData - { - SourceName = sourceForToggle, - FilterName = filterToToggle, - }, - cancellationToken: cancellationToken - ); + // Read then write, both about the same filter. Through the category group that is + // four strings across two request records, any one of which can be misspelled into + // a ResourceNotFound. Held as a handle it is two strings, once. + FilterOperations filter = _obsClient + .Source(sourceForToggle) + .Filter(filterToToggle); + + GetSourceFilterResponseData? currentFilterState = await filter.GetAsync( + cancellationToken + ); if (currentFilterState is null) { @@ -508,17 +527,8 @@ await _obsClient.Filters.GetSourceFilterAsync( return false; } - // 2. Toggle the state bool newState = !currentFilterState.FilterEnabled; - await _obsClient.Filters.SetSourceFilterEnabledAsync( - new SetSourceFilterEnabledRequestData - { - SourceName = sourceForToggle, - FilterName = filterToToggle, - FilterEnabled = newState, - }, - cancellationToken: cancellationToken - ); + await filter.SetEnabledAsync(newState, cancellationToken); UiSuccess( $"Filter '{filterToToggle}' on '{sourceForToggle}' toggled to {(newState ? "ENABLED" : "DISABLED")}" @@ -530,6 +540,10 @@ await _obsClient.Filters.SetSourceFilterEnabledAsync( // Streams are the ergonomic way to observe events: subscribe for the lifetime // of the loop, no handler bookkeeping, and cancellation ends it cleanly. The // classic events on the client are untouched and still work alongside this. + // + // The event already says which scene, by uuid, so acting on it needs no lookup. + // Reading SceneName back off it and addressing the scene by name would add a round + // trip and reintroduce the rename race the uuid exists to close. int seconds = args.Length > 0 && int.TryParse(args[0], out int parsed) ? parsed : 15; UiInfo($"Watching scene changes for {seconds}s. Switch scenes in OBS."); @@ -546,7 +560,14 @@ CurrentProgramSceneChangedEventArgs sceneEvent in _obsClient.Scenes.CurrentProgr ) ) { - UiSuccess($"Program scene is now '{sceneEvent.EventData.SceneName}'"); + GetSceneItemListResponseData items = await _obsClient + .Scene(sceneEvent.EventData.Scene) + .GetItemListAsync(watchCts.Token); + + UiSuccess( + $"Program scene is now '{sceneEvent.EventData.SceneName}' " + + $"({items.SceneItems.Count} item(s))" + ); } } catch (OperationCanceledException) @@ -1143,9 +1164,13 @@ await cycleClient /// /// Validates all three settings API modes for both InputSettings and FilterSettings. - /// All operations are read-then-write-back (overlay:true) so they are non-destructive. - /// Requires at least one browser_source and one input with a gain_filter in OBS. /// + /// + /// The browser source and the gain filter are created here and removed again, so a fresh OBS + /// install exercises the same checks as a populated one. Discovering an existing input instead + /// made the result depend on the machine: the run reported the modes as failing when all that + /// was missing was a source to try them on. + /// private static async Task< List<(string Label, bool Pass, string Detail)> > ValidateSettingsModesAsync( @@ -1162,16 +1187,110 @@ CancellationToken cancellationToken } // ── InputSettings ───────────────────────────────────────────────────── - string? browserInputName = inputs - .Inputs?.FirstOrDefault(i => - string.Equals(i.InputKind, "browser_source", StringComparison.OrdinalIgnoreCase) - ) - ?.InputName; + const string FixtureInputName = "__obsws_settings_browser"; + const string FixtureFilter = "__obsws_settings_gain"; + + string? browserInputName = null; + string? filterSourceName = null; + string? gainFilterName = null; + + try + { + GetCurrentProgramSceneResponseData programScene = await client + .Scenes.GetCurrentProgramSceneAsync(cancellationToken) + .ConfigureAwait(false); + + await client + .Inputs.CreateInputAsync( + inputKind: "browser_source", + inputName: FixtureInputName, + settings: new BrowserSourceSettings( + Url: "https://obsproject.com", + Width: 800, + Height: 600 + ), + sceneName: programScene.SceneName, + sceneItemEnabled: true, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + browserInputName = FixtureInputName; + + await client + .Filters.CreateSourceFilterAsync( + new CreateSourceFilterRequestData( + filterKind: "gain_filter", + filterName: FixtureFilter, + sourceName: FixtureInputName + ), + cancellationToken + ) + .ConfigureAwait(false); + filterSourceName = FixtureInputName; + gainFilterName = FixtureFilter; + } + catch (ObsWebSocketException ex) + { + results.Add(("Settings fixtures", false, $"could not be created: {ex.Message}")); + } + + try + { + results.AddRange( + await RunSettingsModeChecksAsync( + client, + browserInputName, + filterSourceName, + gainFilterName, + cancellationToken + ) + .ConfigureAwait(false) + ); + } + finally + { + // Removing the input takes its filter and its scene item with it. + if (browserInputName is not null) + { + try + { + await client + .Inputs.RemoveInputAsync( + new(inputName: browserInputName), + CancellationToken.None + ) + .ConfigureAwait(false); + } + catch (ObsWebSocketException) + { + // Nothing useful to do about a fixture that will not go away. The next run + // recreates it by the same name and OBS rejects the duplicate visibly. + } + } + } + + return results; + } + + /// + /// The settings-mode checks themselves, against fixtures the caller creates and removes. + /// + private static async Task< + List<(string Label, bool Pass, string Detail)> + > RunSettingsModeChecksAsync( + ObsWebSocketClient client, + string? browserInputName, + string? filterSourceName, + string? gainFilterName, + CancellationToken cancellationToken + ) + { + List<(string Label, bool Pass, string Detail)> results = []; if (string.IsNullOrEmpty(browserInputName)) { results.Add( - ("InputSettings [all modes]", false, "No browser_source in OBS — add one to test") + ("InputSettings [all modes]", false, "fixture browser source was not created") ); } else @@ -1267,45 +1386,10 @@ await client.Inputs.SetInputSettingsAsync( } // ── FilterSettings ──────────────────────────────────────────────────── - // Find first gain_filter across the first 5 inputs. - string? filterSourceName = null; - string? gainFilterName = null; - foreach ( - Core.Protocol.Common.InputStub input in inputs - .Inputs?.Where(i => !string.IsNullOrEmpty(i.InputName)) - .Take(5) - ?? [] - ) - { - try - { - GetSourceFilterListResponseData? fl = await client.Filters.GetSourceFilterListAsync( - new GetSourceFilterListRequestData(sourceName: input.InputName!), - cancellationToken - ); - Core.Protocol.Common.FilterStub? gain = fl?.Filters?.FirstOrDefault(f => - string.Equals(f.FilterKind, "gain_filter", StringComparison.OrdinalIgnoreCase) - ); - if (gain?.FilterName is not null) - { - filterSourceName = input.InputName; - gainFilterName = gain.FilterName; - break; - } - } - catch - { /* skip inputs we can't query */ - } - } - if (string.IsNullOrEmpty(filterSourceName) || string.IsNullOrEmpty(gainFilterName)) { results.Add( - ( - "FilterSettings [all modes]", - false, - "No gain_filter found — add one to an input in OBS" - ) + ("FilterSettings [all modes]", false, "fixture gain filter was not created") ); } else @@ -2150,14 +2234,14 @@ await TrySettingsCheckAsync( "FindSceneItemIdAsync", async () => { - int? id = await client + long? id = await client .SceneItems.FindSceneItemIdAsync( sceneName, inputName, cancellationToken ) .ConfigureAwait(false); - int? miss = await client + long? miss = await client .SceneItems.FindSceneItemIdAsync( sceneName, "__absent__", @@ -2364,7 +2448,7 @@ await TrySettingsCheckAsync( // arrive as double. Writing one and reading it back proves the // retype survives the wire in both directions, which matters most // for MessagePack, where an int and a float are different encodings. - int itemId = + long itemId = await client .SceneItems.FindSceneItemIdAsync( sceneName, @@ -2859,7 +2943,7 @@ await TrySettingsCheckAsync( return (false, "no scene items to reindex"); } - int id = items.SceneItems[0].SceneItemId; + long id = items.SceneItems[0].SceneItemId; int index = items.SceneItems[0].SceneItemIndex; Task reindexed = @@ -3849,7 +3933,7 @@ private ObsWebSocketClientOptions CloneOptionsForFormat(SerializationFormat form }; // --- Helper to find Scene Item ID --- - private async Task GetSceneItemIdAsync( + private async Task GetSceneItemIdAsync( string sceneName, string sourceName, CancellationToken cancellationToken @@ -3867,6 +3951,76 @@ CancellationToken cancellationToken : response.SceneItemId; } + /// + /// Shows what resolving a handle costs, what it buys, and what a miss reports. + /// + /// + /// The rest of the interactive commands address things by name, which is right for a name the + /// operator just typed. This one is the counterpart: it turns a name into a uuid once, and + /// everything after that survives a rename in OBS. + /// + private async Task ResolveDemoAsync( + string sceneName, + string? sourceName, + CancellationToken cancellationToken + ) + { + // One round trip. The protocol has no "uuid of the scene called X" request, so this is + // GetSceneList and a scan. + SceneOperations resolved = await _obsClient + .Scene(sceneName) + .ResolveAsync(cancellationToken); + + RenderKeyValueTable( + "Resolved scene", + [ + ("Given", sceneName), + ("UUID", resolved.Handle.Uuid ?? "N/A"), + ("Survives a rename", resolved.Handle.IsResolved ? "yes" : "no"), + ] + ); + + // Held by uuid, so this reads the same scene even if it is renamed between the two calls. + GetSceneItemListResponseData items = await resolved.GetItemListAsync(cancellationToken); + UiInfo($"'{sceneName}' holds {items.SceneItems.Count} scene item(s)."); + + if (sourceName is not null) + { + // The one lookup that is not a convenience: OBS addresses scene items by a number that + // only GetSceneItemId reports, so an item known by source name cannot be acted on until + // it has been resolved. The type system says so — ItemAsync returns the actable type. + SceneItemOperations item = await resolved.ItemAsync( + sourceName, + cancellationToken: cancellationToken + ); + GetSceneItemEnabledResponseData enabled = await item.GetEnabledAsync(cancellationToken); + + RenderKeyValueTable( + "Resolved scene item", + [ + ("Source", sourceName), + ("Item id", item.Handle.SceneItemId.ToString(CultureInfo.InvariantCulture)), + ("Enabled", enabled.SceneItemEnabled ? "yes" : "no"), + ("Back up to", item.Scene.Handle.Uuid ?? item.Scene.Handle.Name ?? "N/A"), + ] + ); + } + + // A miss is worth showing: the lookup already fetched the list, so the client can name what + // does exist. OBS itself can only answer ResourceNotFound and the name you gave it. + try + { + _ = await _obsClient.Scenes.ResolveAsync( + sceneName + "__no_such_scene", + cancellationToken + ); + } + catch (ObsWebSocketResourceNotFoundException ex) + { + UiInfo($"A miss reports what exists: {ex.Message}"); + } + } + private async Task GetAllSettingsTypesAsync(CancellationToken cancellationToken) { _logger.LogInformation("Fetching all settings type schemas from OBS..."); @@ -4245,13 +4399,19 @@ .. sceneNames.Where(n => n != currentProgramScene).OrderBy(n => n), RestartWhenActive: true ); - int sceneItemId; + // The two paths below reach the same scene item by different routes: creating one answers + // with its id, finding an existing one costs the lookup only OBS can answer. From here on + // the rest of the method does not care which, because both produce the same handle. + SceneItemOperations item; // Step 8: Create new input or update existing source settings if (isNewSource) { UiInfo($"Creating browser source '{sourceName}' in scene '{selectedScene}'..."); + // Protocol level: the typed-settings CreateInput is a hand-written group helper, so it + // has no handle form. Its response carries the new scene item's id, which is what the + // handle below is built from. CreateInputResponseData? createResult = await _obsClient.Inputs.CreateInputAsync( inputKind: "browser_source", inputName: sourceName, @@ -4267,8 +4427,8 @@ .. sceneNames.Where(n => n != currentProgramScene).OrderBy(n => n), return; } - sceneItemId = createResult.SceneItemId; - UiSuccess($"Created '{sourceName}' (scene item ID: {sceneItemId})."); + item = _obsClient.Scene(selectedScene).Item(createResult.SceneItemId); + UiSuccess($"Created '{sourceName}' (scene item ID: {createResult.SceneItemId})."); } else { @@ -4282,19 +4442,14 @@ await _obsClient.Inputs.SetInputSettingsAsync( cancellationToken: cancellationToken ); - sceneItemId = await GetSceneItemIdAsync(selectedScene, sourceName, cancellationToken); - UiSuccess($"Updated '{sourceName}' (scene item ID: {sceneItemId})."); + item = await _obsClient + .Scene(selectedScene) + .ItemAsync(sourceName, cancellationToken: cancellationToken); + UiSuccess($"Updated '{sourceName}' (scene item ID: {item.Handle.SceneItemId})."); } // Step 9: Set Blend Mode to Normal (explicit, even though it is the default) - await _obsClient.SceneItems.SetSceneItemBlendModeAsync( - new SetSceneItemBlendModeRequestData( - sceneItemId: sceneItemId, - sceneItemBlendMode: "OBS_BLEND_NORMAL", - sceneName: selectedScene - ), - cancellationToken: cancellationToken - ); + await item.SetBlendModeAsync("OBS_BLEND_NORMAL", cancellationToken); // The obs-websocket v5 protocol does not expose SetSceneItemPrivateSettings, // so Blending Method (SRGB Off) cannot be set programmatically via this API. @@ -4336,9 +4491,13 @@ private static void RenderCommandHelp() Markup.Escape("Get OBS and WebSocket version info") ); _ = commandTable.AddRow(Markup.Escape("scene"), Markup.Escape("Get current program scene")); + _ = commandTable.AddRow( + Markup.Escape("resolve [scene] [source]"), + Markup.Escape("Resolve a name to a uuid handle, and show what a miss reports") + ); _ = commandTable.AddRow( Markup.Escape("mute [input name]"), - Markup.Escape("Toggle mute for audio input") + Markup.Escape("Toggle mute for audio input, through an input handle") ); _ = commandTable.AddRow( Markup.Escape("unmute [input name]"), @@ -4354,11 +4513,11 @@ private static void RenderCommandHelp() ); _ = commandTable.AddRow( Markup.Escape("list-filters [source]"), - Markup.Escape("List filters for source") + Markup.Escape("List filters for source, through a source handle") ); _ = commandTable.AddRow( Markup.Escape("toggle-filter [source] [filter]"), - Markup.Escape("Toggle filter enabled state") + Markup.Escape("Toggle filter enabled state, through one filter handle") ); _ = commandTable.AddRow( Markup.Escape("media [input] [action]"), @@ -4366,7 +4525,9 @@ private static void RenderCommandHelp() ); _ = commandTable.AddRow( Markup.Escape("watch [seconds]"), - Markup.Escape("Stream scene changes with await foreach (default 15s)") + Markup.Escape( + "Stream scene changes with await foreach, acting on the event's free handle (default 15s)" + ) ); _ = commandTable.AddRow( Markup.Escape("batch-example"), @@ -4453,7 +4614,7 @@ async Task Probe(string name, Func call) GetSceneItemListResponseData items = await client .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) .ConfigureAwait(false); - int sceneItemId = items.SceneItems.Count > 0 ? items.SceneItems[0].SceneItemId : -1; + 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 @@ -5103,7 +5264,7 @@ await Probe( GetSceneItemListResponseData fixtureItems = await client .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) .ConfigureAwait(false); - int itemId = fixtureItems.SceneItems[0].SceneItemId; + long itemId = fixtureItems.SceneItems[0].SceneItemId; // ── Scenes ─────────────────────────────────────────────────────── await Probe( @@ -5199,7 +5360,7 @@ await Probe( ) .ConfigureAwait(false); - int? addedItemId = null; + long? addedItemId = null; try { CreateSceneItemResponseData added = await client @@ -5216,7 +5377,7 @@ await Probe( declined.Add($"CreateSceneItem ({ex.StatusCode})"); } - int? duplicatedItemId = null; + long? duplicatedItemId = null; try { DuplicateSceneItemResponseData duplicated = await client @@ -5235,7 +5396,7 @@ await Probe( // Only the two this sweep added. Removing the last scene item that references an input // destroys the input, which took the audio and media fixtures with it. - foreach (int extra in new[] { addedItemId, duplicatedItemId }.OfType()) + foreach (long extra in new[] { addedItemId, duplicatedItemId }.OfType()) { await Probe( "RemoveSceneItem", From bb1c0a0e8426b6abc010f1293e3b66431304066d Mon Sep 17 00:00:00 2001 From: Agash Date: Mon, 31 Aug 2026 16:13:30 +0200 Subject: [PATCH 09/11] fix(core): complete the stub types against the OBS sources The stubs were written from what one OBS instance happened to return, so fields that instance did not exercise were never typed. Diffing every stub against the C++ that builds the JSON turns up eight: SceneItemStub inputKind, sourceType, sceneItemBlendMode, sceneItemBlendMethod InputStub inputKindCaps OutputStub outputFlags TransformStub cropToBounds They were landing in ExtensionData, so nothing failed, but none of them were reachable as typed members. OutputStub also carried an outputSettings member that GetOutputList never sends; settings come from GetOutputSettings, per output. inputKind and isGroup are nullable because OBS sends null for the case that does not apply, and sceneItemBlendMethod because OBS 32.2.2 does not send it at all. --- .../Protocol/Common/StubTypes.cs | 72 +++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs index f05ccbe..dd441b2 100644 --- a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs +++ b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs @@ -381,6 +381,11 @@ public sealed class SceneItemTransformStub [Key("cropBottom")] public required int CropBottom { get; init; } + /// Whether the crop is applied relative to the bounding box. + [JsonPropertyName("cropToBounds")] + [Key("cropToBounds")] + public bool? CropToBounds { get; init; } + /// Captures any extra fields not explicitly defined in the stub. [IgnoreMember] [JsonExtensionData] @@ -529,6 +534,11 @@ public sealed class SceneItemTransformPatchStub [Key("cropBottom")] public int? CropBottom { get; init; } + /// Whether the crop is applied relative to the bounding box. + [JsonPropertyName("cropToBounds")] + [Key("cropToBounds")] + public bool? CropToBounds { get; init; } + /// Captures any extra fields not explicitly defined in the stub. [IgnoreMember] [JsonExtensionData] @@ -576,11 +586,48 @@ public sealed class SceneItemStub [Key("sceneItemLocked")] public required bool SceneItemLocked { get; init; } - /// Whether the source is a group. + /// + /// Kind of the input the item shows, or when the item is a scene or a + /// group rather than an input. + /// + [JsonPropertyName("inputKind")] + [Key("inputKind")] + public string? InputKind { get; init; } + + /// + /// Type of the source the item shows, as an OBS_SOURCE_TYPE_* value. + /// + [JsonPropertyName("sourceType")] + [Key("sourceType")] + public string? SourceType { get; init; } + + /// + /// Whether the source is a group, or when the item shows an input. + /// + /// + /// OBS answers this only for items that could be a group. An input gets null rather than + /// false, so a null here means "not applicable", not "unknown". + /// [JsonPropertyName("isGroup")] [Key("isGroup")] public bool? IsGroup { get; init; } + /// Blend mode of the scene item, as an OBS_BLEND_* value. + [JsonPropertyName("sceneItemBlendMode")] + [Key("sceneItemBlendMode")] + public string? SceneItemBlendMode { get; init; } + + /// + /// Blend method of the scene item, as an OBS_BLEND_METHOD_* value. + /// + /// + /// Nullable because it is newer than the rest: OBS 32.2.2 does not send it, so requiring it + /// would make the scene item list unreadable on every build that predates the field. + /// + [JsonPropertyName("sceneItemBlendMethod")] + [Key("sceneItemBlendMethod")] + public string? SceneItemBlendMethod { get; init; } + /// Transform data for the scene item. [JsonPropertyName("sceneItemTransform")] [Key("sceneItemTransform")] @@ -665,6 +712,15 @@ public sealed class InputStub [Key("unversionedInputKind")] public required string UnversionedInputKind { get; init; } + /// Capability flags for the input's kind, as an OBS_SOURCE_* bit mask. + /// + /// 64 bits wide because the wire value is: OBS fills it from + /// obs_source_get_output_flags, which returns uint32_t and is not clamped. + /// + [JsonPropertyName("inputKindCaps")] + [Key("inputKindCaps")] + public long? InputKindCaps { get; init; } + /// Captures any extra fields not explicitly defined in the stub. [IgnoreMember] [JsonExtensionData] @@ -745,7 +801,7 @@ public sealed class OutputStub /// /// Wider than a pixel count needs to be, because the wire value is wider. OBS fills this from /// obs_output_get_width, which returns uint32_t and is passed through unclamped, - /// and an idle output can report a value above — a live OBS 32.2.2 + /// and an idle output can report a value above . A live OBS 32.2.2 /// sent 2586032160 for an inactive virtual camera. As an that made the whole /// GetOutputList response unreadable, intermittently, and only for whoever happened to /// have such an output installed. Treat a value over 4096 as "not meaningful", not as a size. @@ -762,10 +818,14 @@ public sealed class OutputStub [Key("outputHeight")] public required long OutputHeight { get; init; } - /// Output settings. - [JsonPropertyName("outputSettings")] - [Key("outputSettings")] - public JsonElement? OutputSettings { get; init; } + /// Capability flags for the output, keyed by OBS_OUTPUT_* name. + /// + /// Replaces an outputSettings member that was here for no reason: nothing in + /// GetOutputList writes one. Settings come from GetOutputSettings, per output. + /// + [JsonPropertyName("outputFlags")] + [Key("outputFlags")] + public Dictionary? OutputFlags { get; init; } /// Captures any extra fields not explicitly defined in the stub. [IgnoreMember] From 4f71c15630e5e0a36a3fd1d8d1ef8b6a462bbeaa Mon Sep 17 00:00:00 2001 From: Agash Date: Mon, 31 Aug 2026 16:13:35 +0200 Subject: [PATCH 10/11] docs: rewrite the readme for length and tone --- README.md | 407 ++++++++++++++++++++++-------------------------------- 1 file changed, 165 insertions(+), 242 deletions(-) diff --git a/README.md b/README.md index 09571fc..9e0efe2 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ # ObsWebSocket.Core -Modern .NET client for OBS Studio WebSocket v5, with generated protocol types and DI-first integration. +A .NET client for the OBS Studio WebSocket v5 protocol, with generated request types and DI-first +integration. [![Build Status](https://img.shields.io/github/actions/workflow/status/Agash/ObsWebSocket/build.yml?branch=master&style=flat-square&logo=github&logoColor=white)](https://github.com/Agash/ObsWebSocket/actions) [![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` -- `net9.0` +Targets `net11.0`, `net10.0` and `net9.0`. ## Install @@ -18,7 +15,8 @@ Modern .NET client for OBS Studio WebSocket v5, with generated protocol types an dotnet add package ObsWebSocket.Core ``` -> **OBS WebSocket v5 only** (OBS Studio 28+). Enable the server via *Tools → WebSocket Server Settings* in OBS. +Requires OBS Studio 28 or newer with obs-websocket v5. Enable the server under +*Tools > WebSocket Server Settings*. ## Quick start @@ -59,6 +57,8 @@ public sealed class Worker(ObsWebSocketClient client) : BackgroundService 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}"); @@ -67,56 +67,41 @@ public sealed class Worker(ObsWebSocketClient client) : BackgroundService } ``` -## Three levels - -The client exposes the protocol at three levels. Most code lives in the middle one and reaches for -the others where they pay. +## Three ways to call OBS -| Level | Looks like | Reach for it when | +| | Example | Use it for | |---|---|---| -| [Handles](#handles) | `client.Input("Mic").SetMuteAsync(true, ct)` | Several calls concern one scene, input, source, item or filter; identity has to survive a rename; you already hold a uuid | -| [Category groups](#the-category-groups) | `client.Inputs.SetInputMuteAsync(new("Mic", true), ct)` | Anything. One method per protocol request, plus the helpers | -| [Raw](#dropping-to-the-low-level) | `client.CallAsync("SetInputMute", data, ct)` | A request this build does not model: a newer OBS, a vendor plugin | +| [Handles](#handles) | `client.Input("Mic").SetMuteAsync(true, ct)` | Requests about one scene, input, source, scene item or filter | +| [Category groups](#category-groups) | `client.Inputs.SetInputMuteAsync(new("Mic", true), ct)` | Everything. One method per protocol request, plus helpers | +| [Raw requests](#raw-requests) | `client.CallAsync("SetInputMute", data, ct)` | Requests this build does not model | -Each level forwards to the one below it, so mixing them costs nothing and no level hides anything -the one below can reach. +Each forwards to the one below it, so they mix freely. -Handles cover the 66 requests that act on one named thing. The rest — `GetVersion`, `GetStats`, -record and stream control, profiles, video settings — are not about a particular thing, so they -exist only on their group. The hand-written helpers (`SetInputVolumeDbAsync`, -`SwitchProgramSceneAndWaitAsync`, the typed settings pairs) live on the group too. +## Category groups -## The category groups - -The client mirrors the categories the OBS protocol defines. Requests, event streams and the -helpers this library adds all sit in the group their category owns: +The client mirrors the categories the protocol defines. Requests, event streams and the helpers this +library adds sit in the group their category owns: ```csharp -await client.Scenes.GetSceneListAsync(new(), ct); // generated request -await client.Scenes.SwitchProgramSceneAndWaitAsync("Intro", cancellationToken: ct); // convenience +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) => { }; // classic event +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`. They come from the -protocol definition, so a refresh that recategorises a request moves it here too. +`Record`, `SceneItems`, `Scenes`, `Sources`, `Stream`, `Transitions` and `Ui`. -`WaitForEventAsync` and `CallBatchAsync` stay directly on the client, since neither belongs to one +`WaitForEventAsync` and `CallBatchAsync` sit on the client itself, since neither belongs to a category. ## Handles -Every OBS request that acts on something takes its identity as two optional fields, a name and a -uuid, and resolves them in a fixed order: a uuid wins outright, a name is read only when no uuid was -sent, the canvas is consulted only on the name path, and neither field present is -`MissingRequestField`. So `new SetCurrentProgramSceneRequestData()` compiles and fails at runtime, -and passing both silently ignores the name. - -A handle makes that choice once. A string is a name, a `Guid` is a uuid: +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: ```csharp await client.Scene("Intro").SetCurrentProgramAsync(ct); @@ -125,161 +110,145 @@ 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 every request -the protocol defines about that kind of thing, named without the part the handle already says: +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 stays in the XML docs and on the category group. +input. The protocol name is in the XML docs and on the category group. -Handles hold identity, nothing else. They cache no state and are safe to keep for the life of an -application. +Requests that are not about a particular thing, such as `GetVersion`, `GetStats` and the record and +stream controls, are on their group only. -### Resolving a name to a uuid +### Names and uuids -A name handle is fine when you just typed the name. A uuid handle survives a rename, and is the only -form OBS will accept once it drops names, which the maintainers have said is the plan. - -Resolving costs a round trip, so it is explicit: +A name works, but breaks if the thing is renamed. Resolving a name to a uuid costs one round trip: ```csharp SceneOperations intro = await client.Scene("Intro").ResolveAsync(ct); -// intro.Handle.IsResolved is true; a rename in OBS can no longer move it +// intro.Handle.IsResolved is true, and a rename no longer affects it ``` -The protocol has no narrow lookup — nothing answers "what is the uuid of the scene called X" — so -this is `GetSceneList` and a scan. The list is not wasted: on a miss you get the names that do -exist, where OBS itself can only answer `ResourceNotFound`. +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 that cost nothing +### Handles from events and responses -An event already says which scene it concerns, by uuid. Reading the name back off it and addressing -by name again is the round trip and the rename race that the uuid was there to avoid: +Events and responses that carry a uuid expose a handle for it, so acting on one needs no lookup: ```csharp client.Scenes.CurrentProgramSceneChanged += async (_, e) => - await client.Scene(e.EventData.Scene).GetItemListAsync(); // already resolved -``` - -Forty-seven events and responses carry one, including the creation requests, which answer with the -uuid of what they just made: + await client.Scene(e.EventData.Scene).GetItemListAsync(); -```csharp CreateSceneResponseData created = await client.Scenes.CreateSceneAsync(new("Intro"), ct); -await client.Scene(created.Scene).SetCurrentProgramAsync(ct); // no lookup +await client.Scene(created.Scene).SetCurrentProgramAsync(ct); ``` ### Scene items -Scene items are the one case where a lookup is not a convenience: OBS addresses them by a number -that only `GetSceneItemId` reports. A scene item known by its source name is therefore a different -type from one that can be acted on, and the missing lookup is a compile error rather than a runtime -one: +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: ```csharp SceneItemOperations logo = await client.Scene("Intro").ItemAsync("Logo", cancellationToken: ct); await logo.SetEnabledAsync(false, ct); -await logo.Scene.GetItemListAsync(ct); // navigate back up +await logo.Scene.GetItemListAsync(ct); -await client.Scene("Intro").Item(3).SetIndexAsync(0, ct); // an id needs no lookup +await client.Scene("Intro").Item(3).SetIndexAsync(0, ct); // an id needs no lookup ``` -`Item(int)` and `Filter(string)` never send anything, since an id and a filter name are already the -whole identity. +`Item(long)` and `Filter(string)` send nothing, since an id and a filter name are the whole +identity. ### Canvases -A canvas has no name in the protocol: every canvas-scoped request takes a uuid, and `canvasName` -appears only in `GetCanvasList`. `CanvasHandle` is the one handle whose name form cannot be sent -anywhere before it is resolved: +Canvas-scoped requests take a uuid; `canvasName` appears only in `GetCanvasList`. Resolve a canvas +by name to use it: ```csharp 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` rather than a null check at -every call site. A canvas only scopes a *name*: OBS ignores `canvasUuid` beside a uuid, so a resolved -handle drops it. +Omitting the canvas means the main one, which is `CanvasHandle.Main`. A canvas scopes a name only, +so a resolved handle drops it. -## The helper set +## Helpers -Alongside the generated request per protocol request, each group carries helpers for things that -otherwise take several calls or a lookup. These are hand-written, so they exist only on the group, -not on a handle. +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. -Every typed settings helper has two overloads: an implicit one for library-registered types, and an -explicit one taking a `JsonTypeInfo` for consumer-provided types. Use the explicit overload to -stay AOT-safe. +Typed settings helpers have two overloads: an implicit one for library-registered types, and an +explicit one taking a `JsonTypeInfo` for your own types. Use the explicit overload under +Native AOT. -**Settings read and write** +**Settings** | Helper | Notes | |---|---| | `Inputs.GetInputSettingsAsync` / `SetInputSettingsAsync` | Input settings; Set supports `overlay` | -| `Inputs.GetInputDefaultSettingsAsync` | Defaults for a given input kind | +| `Inputs.GetInputDefaultSettingsAsync` | Defaults for an input kind | | `Filters.GetSourceFilterSettingsAsync` / `SetSourceFilterSettingsAsync` | Filter settings; Set supports `overlay` | -| `Filters.GetSourceFilterDefaultSettingsAsync` | Defaults for a given filter kind | +| `Filters.GetSourceFilterDefaultSettingsAsync` | Defaults for a filter kind | | `Transitions.GetCurrentSceneTransitionSettingsAsync` / `SetCurrentSceneTransitionSettingsAsync` | Transition settings | | `Outputs.GetOutputSettingsAsync` / `SetOutputSettingsAsync` | Output settings | | `Config.GetStreamServiceSettingsAsync` / `SetStreamServiceSettingsAsync` | Stream service settings | -Most take optional parameters ahead of the cancellation token, so pass it as `cancellationToken: ct`. +Most take optional parameters before the cancellation token, so pass it as `cancellationToken: ct`. **Scenes and scene items** -- `Scenes.SwitchProgramSceneAsync(scene, ct)` and `Scenes.SwitchPreviewSceneAsync(scene, ct)` switch - a scene. Optional `transitionName` and `transitionDurationMs` apply to that switch only. -- `Scenes.SwitchProgramSceneAndWaitAsync` and `Scenes.SwitchPreviewSceneAndWaitAsync` do the same - and wait for the event confirming it. +- `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. Leave `isEnabled` null to toggle. An overload takes the numeric item id instead. -- `SceneItems.FindSceneItemIdAsync(scene, sourceName, ct)` returns `int?`, null rather than throwing - when the item is not in the scene. -- `Sources.SourceExistsAsync(name, ct)` and `Scenes.SceneExistsAsync(name, ct)` check existence. + 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)` is shorthand for updating text source content. +- `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 - results, so a caller sees which inputs OBS rejected. + per-input results. - `Inputs.CreateInputAsync(kind, name, settings, ...)` creates an input with typed settings. - `Filters.CreateSourceFilterAsync(source, filterName, kind, settings, ct)` adds a typed filter. **Media** -- `MediaInputs.PlayMediaAsync`, `PauseMediaAsync`, `StopMediaAsync` and `RestartMediaAsync` are - shorthands over `TriggerMediaActionAsync(name, MediaInputAction, ct)`. +- `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 straight to disk. +- `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 OBS to confirm, returning the resulting `OutputState`. + or stop the output and wait for confirmation, returning the resulting `OutputState`. - `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 rather than throwing when it does - not exist. + switch only if needed, returning whether the target is active. - `General.TriggerHotkeyAsync(hotkeyName, ct)` fires a hotkey by name. -## Observing events +## Events -Every OBS event is exposed as an async sequence on its category group. The stream subscribes for the -lifetime of the loop and unsubscribes when it ends, so there is no handler bookkeeping: +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: ```csharp await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellationToken: ct)) @@ -288,25 +257,23 @@ await foreach (var e in client.Scenes.CurrentProgramSceneChangedStream(cancellat } ``` -Streams buffer a bounded number of events and drop the oldest when a consumer falls behind, so a -slow loop cannot stall the receive loop. Pass `capacity` to change that. +Streams buffer a bounded number of events and drop the oldest when a consumer falls behind. Pass +`capacity` to change that. -The classic handler sits on the same group, so subscribing and streaming read alike and there is no -second place to look: +The classic handler is on the same group: ```csharp 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 and a handler added through one can -be removed through the other. +The group's event is the client's event, so both work at once. -Connection lifecycle events stay on the client, since `Connected`, `Disconnected`, -`ConnectionFailed` and `AuthenticationFailure` belong to no protocol category. +`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 without racing it: +start the wait and then trigger the action: ```csharp var changed = await client.WaitForEventAsync(ct); @@ -318,26 +285,24 @@ var intro = await client.WaitForEventAsync( ); ``` -It throws `ObsWebSocketTimeoutException` when the wait elapses, the same type a request -timeout raises, so one `catch (ObsWebSocketException)` covers both. +It throws `ObsWebSocketTimeoutException` when the wait elapses. -## Common use cases +## Common tasks ### Update a text source ```csharp await client.Inputs.SetInputTextAsync("NewsTicker", "Breaking: Live now!", ct); -// or several properties at once, with a typed settings object var settings = new TextGdiPlusInputSettings(Text: "Breaking: Live now!", WordWrap: true); await client.Inputs.SetInputSettingsAsync("NewsTicker", settings, cancellationToken: ct); ``` -`TextGdiPlusInputSettings` is a built-in library type, as are `TextFreetype2InputSettings`, -`BrowserSourceSettings` and the filter settings types, which live in -`ObsWebSocket.Core.Protocol.Common.InputSettings` and `.FilterSettings`. +`TextGdiPlusInputSettings`, `TextFreetype2InputSettings`, `BrowserSourceSettings` and the filter +settings types are built in, under `ObsWebSocket.Core.Protocol.Common.InputSettings` and +`.FilterSettings`. -### Check and save the replay buffer +### Save the replay buffer ```csharp var status = await client.Outputs.GetReplayBufferStatusAsync(ct); @@ -360,7 +325,7 @@ await client.Inputs.SetInputSettingsAsync( ); ``` -Define your own type to target exactly what you need, and stay AOT-safe by passing its `JsonTypeInfo`: +For settings this library does not model, define your own type and pass its `JsonTypeInfo`: ```csharp [JsonSerializable(typeof(OverlaySettings))] @@ -379,8 +344,8 @@ await client.Inputs.SetInputSettingsAsync( ); ``` -Both `Set` overloads take `overlay` before the cancellation token. It defaults to `true`, merging -your values onto the existing settings; pass `overlay: false` to replace them. +`overlay` comes before the cancellation token and defaults to true, merging your values onto the +existing settings. Pass `overlay: false` to replace them. ### Screenshots @@ -411,13 +376,11 @@ Console.WriteLine(results.Get(version).ObsVersion); Console.WriteLine(results.Get(scenes).Scenes?.Count); ``` -`results.Get(reference)` restates neither the position nor the type, so a request type may appear -many times in one batch and each reference still resolves to its own result. - -`Sleep` is only valid inside a batch, and pairs with `SerialRealtime` to pace a sequence. +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, and `Get` throws -`ObsWebSocketRequestException` carrying the OBS status code when that request was rejected: +`TryGet` reports a failed or missing result instead of throwing. `Get` throws +`ObsWebSocketRequestException` carrying the OBS status code: ```csharp if (!results.AllSucceeded()) @@ -429,45 +392,41 @@ if (!results.AllSucceeded()) } ``` -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. +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`, and an overload -taking a `JsonTypeInfo` keeps a custom payload AOT-safe: +`Add` covers anything the generated methods do not, including a raw `JsonElement`, with an overload +taking a `JsonTypeInfo`: ```csharp batch.Add("GetStats"); batch.Add("SetInputSettings", myJsonElement); ``` -### Running requests in parallel +### Parallel batches -`RequestBatchExecutionType.Parallel` works, but OBS mislabels what comes back. It collects results in -completion order and labels them from the submission order, so on any row the `requestType` and -`requestId` name a different request than the `requestStatus` and `responseData` beside them. That -happens inside OBS, so it cannot be corrected here. See -[#16](https://github.com/Agash/ObsWebSocket/issues/16). +`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](https://github.com/Agash/ObsWebSocket/issues/16). -Only the labelling is wrong — 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`. -Anything that does not depend on which row is which stays exact: +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: ```csharp BatchResults results = await client.CallBatchAsync( batch, executionType: RequestBatchExecutionType.Parallel, cancellationToken: ct); -bool everythingWorked = results.AllSucceeded(); // reliable: order does not change the verdict -int failureCount = results.GetFailures().Count(); // reliable count, unreliable names +bool everythingWorked = results.AllSucceeded(); +int failureCount = results.GetFailures().Count(); ``` -`results.Raw` still reaches every payload, and `GetData` reads one without consulting the label, -so a batch where every request returns the same type is fully recoverable. A batch of mixed types is -not: `GetData` rejects a payload carrying none of `T`'s fields, but that rejects rather than -identifies, and two records with the same field names cannot be told apart. +`results.Raw` reaches every payload, and `GetData` reads one without consulting the label, so a +batch where every request returns the same type is fully recoverable. -So `Parallel` suits a set of writes you want applied as fast as possible and only need a pass/fail -on. When you need answers attributed to requests, send them concurrently instead — the client -multiplexes on the request id: +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: ```csharp Task version = client.General.GetVersionAsync(ct); @@ -478,65 +437,54 @@ 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. -## Dropping to the low level +## Raw requests -Every generated request is a thin wrapper over the same primitives, which stay available for a -request this build does not model, an OBS newer than this library, or a vendor plugin: +Every generated request wraps the same primitives, which stay available for requests this build does +not model, a newer OBS, or a vendor plugin: ```csharp -// A request with a reference type response. +// Reference type response. GetVersionResponseData? v = await client.CallAsync("GetVersion", null, cancellationToken: ct); -// A value type response, JsonElement included. CallAsync is constrained to classes, so a struct -// response goes through CallAsyncValue. +// Value type response, including JsonElement. CallAsync is constrained to classes. JsonElement? raw = await client.CallAsyncValue("GetStats", null, 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 has no metadata -// anywhere and throws ObsWebSocketSerializationException. - -// Your own type, with your own context. AOT safe, and nothing to hand build. +// Your own request type, with your own context. [JsonSerializable(typeof(MyRequest))] internal sealed partial class MyContext : JsonSerializerContext; JsonElement? answer = await client.CallAsyncValue( "SomeNewRequest", new MyRequest(1), MyContext.Default.MyRequest, cancellationToken: ct); -// Or a JsonElement built by hand, when a one-off payload does not deserve a type. +// Or a JsonElement built by hand. using JsonDocument body = JsonDocument.Parse("""{"someField":1}"""); JsonElement? viaElement = await client.CallAsyncValue( "SomeNewRequest", body.RootElement, cancellationToken: ct); -// A batch assembled by hand, without the typed builder. +// A batch without the typed builder. List> results = await client.CallBatchAsync( [new BatchRequestItem("GetVersion", null), new BatchRequestItem("GetStats", null)], executionType: RequestBatchExecutionType.SerialRealtime, cancellationToken: ct); - -foreach (RequestResponsePayload result in results) -{ - GetVersionResponseData? data = result.GetData(); -} ``` -Those two are the AOT-safe ways to build a payload. `JsonSerializer.SerializeToElement` without a -`JsonTypeInfo`, and the `JsonNode` and `JsonObject` routes, all work at runtime but carry `IL2026` -and `IL3050`, so they are not options under Native AOT. +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()` / `FromWireValue()` convert an enum to and from the -protocol string when you are building a payload by hand. +`client.Scenes.SceneCreated`, and `ToWireValue()` and `FromWireValue()` convert an enum to and from +the protocol string. ## Protocol types -The protocol definition is looser than C#: it has one numeric type because JSON does, and it types -enum-valued fields as plain strings. The generated surface narrows both, so callers get the C# type -rather than the wire representation. +The protocol definition has one numeric type and describes enum-valued fields as plain strings. The +generated types narrow both. -**Numbers.** A scene item id and a volume multiplier are both `Number` with a `>= 0` restriction, so -which ones are integral is not recoverable from the definition. Fields holding whole numbers are -generated as `int` or `long`, from an explicit list in the generator rather than a rule over field -names, so a volume can never be truncated by a naming coincidence: +**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: ```csharp long id = await client.SceneItems.FindSceneItemIdAsync("Intro", "Logo", ct) ?? throw new(...); @@ -546,16 +494,8 @@ long bytes = (await client.Stream.GetStreamStatusAsync(ct)).OutputBytes; double volume = (await client.Inputs.GetInputVolumeAsync(new("Mic"), ct)).InputVolumeMul; ``` -The width comes from what fills the field upstream, not from how large the value looks. Where -obs-websocket validates a range — resolutions are 8..4096, indices 0..8192 — `int` is enough. -Where it copies a value straight out of libobs, the C type decides: scene item ids and settings -durations are `int64_t`, frame counters and output dimensions are `uint32_t`, and session message -counts are `uint64_t`, so all of those are `long`. This is not cosmetic. An out-of-range value does -not truncate one field, it fails the whole response — an idle virtual camera reporting an -uninitialised `outputHeight` made every `GetOutputList` unreadable. - -**Enums.** Fields carrying a protocol enum are that enum, on both the read and the write side, so -there is nothing to convert at the call site: +**Enums.** Fields carrying a protocol enum are typed as that enum on both the read and the write +side: ```csharp client.Outputs.StreamStateChanged += (_, e) => @@ -565,7 +505,7 @@ client.Outputs.StreamStateChanged += (_, e) => OutputState.Started => "live", OutputState.Starting or OutputState.Reconnecting => "coming up", OutputState.Stopped or OutputState.Stopping => "going down", - OutputState.Unknown => "in a state this build does not recognise", + OutputState.Unknown => "unrecognised", _ => "in between", }; }; @@ -573,17 +513,11 @@ client.Outputs.StreamStateChanged += (_, e) => await client.MediaInputs.TriggerMediaActionAsync("Stinger", MediaInputAction.Restart, ct); ``` -A value OBS sends that this build does not know maps to the enum's zero member rather than throwing, -so a state added by a newer OBS does not fail the whole message. +A value this build does not know maps to the enum's zero member rather than throwing. -This covers the enums the protocol declares. `mediaState`, `monitorType`, `sceneItemBlendMode` and -`inputKind` carry fixed vocabularies too, but the protocol types them as strings and never lists -their values, so they stay strings rather than being given an enum this library would have to keep -correct by hand. - -The wire values also remain as `const` strings on `ObsOutputState` and `ObsMediaInputAction`, and -`ToWireValue()` converts an enum back, for payloads built by hand. See -[Dropping to the low level](#dropping-to-the-low-level). +`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 @@ -593,13 +527,13 @@ builder.AddObsWebSocketClient("obs") // reads ConnectionStrings:obs .WithHealthCheck(); ``` -The password may 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, because -OBS is often started after the application, and reconnect takes over from there. +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 editing configuration takes effect without a restart. -Timeouts and reconnect settings apply to the next call that uses them; changing the endpoint, -password or transport reconnects, which `WithAutoConnect` performs. +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. To configure in code instead: @@ -612,7 +546,6 @@ builder.Services.AddObsWebSocketClient(o => }); ``` - ### Multiple OBS instances Register clients by name and resolve them with `[FromKeyedServices]`: @@ -630,13 +563,10 @@ public sealed class Worker( [FromKeyedServices("booth")] ObsWebSocketClient booth); ``` -Each client gets its own options instance, its own connection service and a health check named after -its key, so the two do not collide. +Each client gets its own options, connection service and health check named after its key. ## Errors -Failures are typed, so they can be caught by category rather than matched by message: - ```csharp try { @@ -652,8 +582,7 @@ catch (ObsWebSocketTimeoutException) } ``` -`StatusCode` reports the status as the `RequestStatusCode` enum, so a filter can name the reason -instead of a number: +`StatusCode` is the `RequestStatusCode` enum, so a filter can name the reason: ```csharp using ObsWebSocket.Core.Protocol.Generated; @@ -664,18 +593,17 @@ catch (ObsWebSocketRequestException ex) when (ex.StatusCode is RequestStatusCode } ``` -`ObsWebSocketSerializationException` covers payloads that cannot be written or read, and all three +`ObsWebSocketSerializationException` covers payloads that cannot be written or read. All three derive from `ObsWebSocketException`. - ## 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 never retried, since they cannot succeed on a second attempt. +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. -`WithReconnectPipeline()` registers the default pipeline explicitly, which is worth doing when a -host has its own resilience configuration and you want this client's to be visible alongside it: +`WithReconnectPipeline()` registers the default pipeline explicitly, which is useful when a host has +its own resilience configuration: ```csharp builder.AddObsWebSocketClient("obs") @@ -683,7 +611,7 @@ builder.AddObsWebSocketClient("obs") .WithReconnectPipeline(); ``` -To replace the policy outright rather than tune those options, register your own pipeline under +To replace the policy, register your own pipeline under `ObsWebSocketResilience.ReconnectPipelineKey` after adding the client. ## Telemetry @@ -698,33 +626,28 @@ builder.Services.AddOpenTelemetry() ``` 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. The -instruments are created from `IMeterFactory`, so they belong to the container that built them. +requests failed, events received and reconnect attempts, plus a request duration histogram. +Instruments are created from `IMeterFactory`. Timeouts and reconnect delays run on an injectable `TimeProvider`, so tests can drive them with -`FakeTimeProvider` instead of waiting. +`FakeTimeProvider`. ## Serialization JSON and MessagePack are both supported, selected with `Format`. Everything in this document behaves -identically on either, and the validation suite exercises both. +the same on either. ## Example app `ObsWebSocket.Example` is a host-based sample with configuration and DI. -- **Interactive mode**: a command loop — `help` lists it. `mute`, `list-filters` and `toggle-filter` - go through handles, `set-text` and `get-input-settings` deliberately do not, and `resolve` shows - what resolving buys and costs. -- **Validation mode**: `ObsWebSocket.Example run-transport-tests` runs the same checks on JSON and - on MessagePack against a scene, input and filter it creates and removes itself. +- Interactive mode: a command loop, listed by `help`. +- Validation mode: `ObsWebSocket.Example run-transport-tests` runs the same checks over JSON and + MessagePack against a scene, input and filter it creates and removes itself. -The validation run asserts real values rather than that a call returned. It covers the three -settings modes, event streams and their buffering, `WaitForEventAsync`, the typed batch builder -including duplicate request types and partial failure, parallel batches, the low-level path, typed -enums, screenshots, and the handles — including that a uuid handle still resolves after a rename and -a name handle does not. It also calls every read request and every safely sendable write request in -the protocol, and fails on any response it cannot deserialize. +The validation run covers the settings helpers, event streams, `WaitForEventAsync`, the batch +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. ## Native AOT @@ -734,7 +657,7 @@ dotnet publish ObsWebSocket.Example/ObsWebSocket.Example.csproj -c Release -r wi ## Contributing -Contributions are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md). +See [`CONTRIBUTING.md`](CONTRIBUTING.md). ## License From 805e284c706b3d6a18e4f2e73e35b50b6e644d09 Mon Sep 17 00:00:00 2001 From: Agash Date: Mon, 31 Aug 2026 16:13:35 +0200 Subject: [PATCH 11/11] style: run csharpier, and drop the em dashes CI checks formatting and the last three commits were not formatted. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CONTRIBUTING.md | 4 ++-- .../Generation/NumericFieldTable.cs | 4 ++-- .../AuthenticationFailureException.cs | 2 +- .../ConnectionAttemptFailedException.cs | 2 +- ObsWebSocket.Core/Groups/SceneItemsGroup.cs | 1 - ObsWebSocket.Core/Groups/SourcesGroup.cs | 4 ++-- .../FilterSettings/CommonFilterSettings.cs | 14 +++++++------- .../InputSettings/CommonInputSettings.cs | 2 +- ObsWebSocket.Example/Worker.cs | 18 ++++++++---------- ObsWebSocket.Tests/FalsyRequestFieldTests.cs | 2 +- ObsWebSocket.Tests/StubArrayTests.cs | 15 +++++++-------- 12 files changed, 33 insertions(+), 37 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6ce1224..023c835 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,7 +6,7 @@ body: attributes: value: | Thanks for taking the time to file this. Please do not report security - vulnerabilities here — use a [private advisory](../../security/advisories/new) instead. + vulnerabilities here. Use a [private advisory](../../security/advisories/new) instead. - type: textarea id: what-happened attributes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cee75a..e039b8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,7 +111,7 @@ Thank you again for your interest in contributing! - Name tests `{Method}_{Scenario}_{ExpectedResult}`. - Prefer the purpose-built MSTest assertions (`Assert.HasCount`, `Assert.Contains`, - `Assert.AreSequenceEqual`) over hand-rolled equality checks — the analyzers will point you at them. + `Assert.AreSequenceEqual`) over hand-rolled equality checks; the analyzers will point you at them. - No `Thread.Sleep`. Use `TaskCompletionSource`, channels, or a fake clock. - New behaviour needs a test. Bug fixes need a test that fails before the fix. @@ -124,7 +124,7 @@ fix(webhooks): reject a signature computed over the decoded body ``` Keep the subject under 50 characters and in the imperative mood. Add a body only when the reason for -the change would not be obvious to the next reader — explain *why*, not *what*. +the change would not be obvious to the next reader. Explain *why*, not *what*. One logical change per commit. Rebase rather than merge when updating a branch. diff --git a/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs b/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs index 637c323..6a54e48 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs @@ -17,7 +17,7 @@ namespace ObsWebSocket.Codegen.Tasks.Generation; /// is whether obs-websocket bounds it. A field validated with ValidateNumber or /// ValidateOptionalNumber has a stated range and is safe at its natural width; a field /// copied straight out of libobs or a settings blob is as wide as the C type behind it, and most of -/// those are uint32_t or int64_t. Getting this wrong is not a truncated field — the +/// those are uint32_t or int64_t. Getting this wrong is not a truncated field: the /// response fails to deserialize, so one out-of-range pixel count takes the whole message with it. /// Check the request handler before adding a field here. /// @@ -57,7 +57,7 @@ internal static class NumericFieldTable /// /// /// The frame and message counters are here because of what fills them, not because the numbers - /// look large. A field is safe as an int only when obs-websocket bounds it — the + /// look large. A field is safe as an int only when obs-websocket bounds it. The /// resolutions are all validated to 8..4096, and the indices are container positions. These /// are copied out of libobs and the session with no clamp in between: /// obs_get_total_frames and obs_get_lagged_frames return uint32_t, diff --git a/ObsWebSocket.Core/AuthenticationFailureException.cs b/ObsWebSocket.Core/AuthenticationFailureException.cs index 74d8383..b99ee87 100644 --- a/ObsWebSocket.Core/AuthenticationFailureException.cs +++ b/ObsWebSocket.Core/AuthenticationFailureException.cs @@ -1,7 +1,7 @@ namespace ObsWebSocket.Core; /// -/// Thrown when authentication against the OBS WebSocket server fails — typically because the +/// Thrown when authentication against the OBS WebSocket server fails, typically because the /// configured password is wrong, missing, or otherwise rejected by the server's challenge. /// /// diff --git a/ObsWebSocket.Core/ConnectionAttemptFailedException.cs b/ObsWebSocket.Core/ConnectionAttemptFailedException.cs index 02061f0..69c55d0 100644 --- a/ObsWebSocket.Core/ConnectionAttemptFailedException.cs +++ b/ObsWebSocket.Core/ConnectionAttemptFailedException.cs @@ -2,7 +2,7 @@ namespace ObsWebSocket.Core; /// /// Thrown when a single connection attempt to the OBS WebSocket server fails for a non-auth -/// reason — protocol mismatch, transport error, handshake timeout, or a server-rejected +/// reason: protocol mismatch, transport error, handshake timeout, or a server-rejected /// configuration. Distinct from so consumers can /// decide whether to retry or stop. /// diff --git a/ObsWebSocket.Core/Groups/SceneItemsGroup.cs b/ObsWebSocket.Core/Groups/SceneItemsGroup.cs index 9accdac..d862b25 100644 --- a/ObsWebSocket.Core/Groups/SceneItemsGroup.cs +++ b/ObsWebSocket.Core/Groups/SceneItemsGroup.cs @@ -155,5 +155,4 @@ public async Task SetSceneItemEnabledAsync( } // Let other ObsWebSocketExceptions or different exception types propagate } - } diff --git a/ObsWebSocket.Core/Groups/SourcesGroup.cs b/ObsWebSocket.Core/Groups/SourcesGroup.cs index 898a9dc..76b3586 100644 --- a/ObsWebSocket.Core/Groups/SourcesGroup.cs +++ b/ObsWebSocket.Core/Groups/SourcesGroup.cs @@ -163,7 +163,7 @@ await client /// Optional output width. uses the source width. /// Optional output height. uses the source height. /// - /// JPEG compression quality 0–100 (-1 uses the OBS default). + /// JPEG compression quality 0 to 100 (-1 uses the OBS default). /// Ignored for lossless formats. /// /// @@ -222,7 +222,7 @@ public async Task GetSourceScreenshotOnCanvasBytesAsync( /// Image format: "png", "jpg", or "bmp". /// Optional output width. uses the source width. /// Optional output height. uses the source height. - /// JPEG compression quality 0–100 (-1 uses the OBS default). + /// JPEG compression quality 0 to 100 (-1 uses the OBS default). /// /// Optional source UUID for unambiguous identification. /// When the lookup is by alone. diff --git a/ObsWebSocket.Core/Protocol/Common/FilterSettings/CommonFilterSettings.cs b/ObsWebSocket.Core/Protocol/Common/FilterSettings/CommonFilterSettings.cs index 9fee89e..45b0328 100644 --- a/ObsWebSocket.Core/Protocol/Common/FilterSettings/CommonFilterSettings.cs +++ b/ObsWebSocket.Core/Protocol/Common/FilterSettings/CommonFilterSettings.cs @@ -3,7 +3,7 @@ namespace ObsWebSocket.Core.Protocol.Common.FilterSettings; /// Settings for the 'mask_filter' or 'mask_filter_v2' filter (Image Mask/Blend). -/// v1 uses opacity 0–100; v2 uses 0.0–1.0. +/// v1 uses opacity 0 to 100; v2 uses 0.0 to 1.0. public sealed record ImageMaskBlendFilterSettings( [property: JsonPropertyName("type")] string? Type = null, [property: JsonPropertyName("color")] long? Color = null, @@ -46,8 +46,8 @@ public sealed record HdrTonemapFilterSettings( /// /// Settings for the 'color_filter' (Color Correction v1) or 'color_filter_v2' filter. -/// v1 includes and uses opacity on a 0–100 scale. -/// v2 replaces with / and uses opacity 0.0–1.0. +/// v1 includes and uses opacity on a 0 to 100 scale. +/// v2 replaces with / and uses opacity 0.0 to 1.0. /// Null properties are skipped on overlay writes, so this type works for both versions. /// public sealed record ColorCorrectionFilterSettings( @@ -86,7 +86,7 @@ public sealed record GpuDelayFilterSettings; /// /// Settings for the 'color_key_filter' or 'color_key_filter_v2' filter. -/// v1 uses opacity 0–100; v2 uses 0.0–1.0. +/// v1 uses opacity 0 to 100; v2 uses 0.0 to 1.0. /// public sealed record ColorKeyFilterSettings( [property: JsonPropertyName("key_color_type")] string? KeyColorType = null, @@ -113,7 +113,7 @@ public sealed record SharpnessFilterSettings( /// /// Settings for the 'chroma_key_filter' or 'chroma_key_filter_v2' filter. -/// v1 uses opacity 0–100; v2 uses 0.0–1.0. +/// v1 uses opacity 0 to 100; v2 uses 0.0 to 1.0. /// public sealed record ChromaKeyFilterSettings( [property: JsonPropertyName("key_color_type")] string? KeyColorType = null, @@ -188,10 +188,10 @@ public static class DetectorValues /// Known values for the setting. public static class PresetsValues { - /// Expander — increases dynamic range below the threshold. + /// Expander. Increases dynamic range below the threshold. public const string Expander = "expander"; - /// Gate — silences audio below the threshold. + /// Gate. Silences audio below the threshold. public const string Gate = "gate"; } } diff --git a/ObsWebSocket.Core/Protocol/Common/InputSettings/CommonInputSettings.cs b/ObsWebSocket.Core/Protocol/Common/InputSettings/CommonInputSettings.cs index 1f590cb..fe524ce 100644 --- a/ObsWebSocket.Core/Protocol/Common/InputSettings/CommonInputSettings.cs +++ b/ObsWebSocket.Core/Protocol/Common/InputSettings/CommonInputSettings.cs @@ -269,7 +269,7 @@ public sealed record WasapiOutputCaptureSettings( ); /// Settings for the 'dshow_input' (Video Capture Device / DirectShow) input. -/// Device-identifying properties (device, device_id, video_device_id, audio_device_id) are not included — use a consumer-defined type to target a specific device. +/// Device-identifying properties (device, device_id, video_device_id, audio_device_id) are not included. Use a consumer-defined type to target a specific device. public sealed record DShowInputSettings( [property: JsonPropertyName("active")] bool? Active = null, [property: JsonPropertyName("hw_decode")] bool? HwDecode = null, diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 5f7d9e4..19afeac 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -511,9 +511,7 @@ await _obsClient.Inputs.SetInputTextAsync( // Read then write, both about the same filter. Through the category group that is // four strings across two request records, any one of which can be misspelled into // a ResourceNotFound. Held as a handle it is two strings, once. - FilterOperations filter = _obsClient - .Source(sourceForToggle) - .Filter(filterToToggle); + FilterOperations filter = _obsClient.Source(sourceForToggle).Filter(filterToToggle); GetSourceFilterResponseData? currentFilterState = await filter.GetAsync( cancellationToken @@ -1136,8 +1134,8 @@ await SweepEveryWriteRequestAsync(cycleClient, cancellationToken) _ = summary.AddRow( Markup.Escape(label), pass - ? $"[green]Pass[/] — {Markup.Escape(detail)}" - : $"[red]Fail[/] — {Markup.Escape(detail)}" + ? $"[green]Pass[/]: {Markup.Escape(detail)}" + : $"[red]Fail[/]: {Markup.Escape(detail)}" ); } foreach ((string label, bool pass, string detail) in modernResults) @@ -3988,7 +3986,7 @@ CancellationToken cancellationToken { // The one lookup that is not a convenience: OBS addresses scene items by a number that // only GetSceneItemId reports, so an item known by source name cannot be acted on until - // it has been resolved. The type system says so — ItemAsync returns the actable type. + // it has been resolved. The type system says so: ItemAsync returns the actable type. SceneItemOperations item = await resolved.ItemAsync( sourceName, cancellationToken: cancellationToken @@ -4337,7 +4335,7 @@ .. sceneNames.Where(n => n != currentProgramScene).OrderBy(n => n), .ToList() ?? []; - // Step 4: Prompt — create new source or update an existing browser source + // Step 4: Prompt to create a new source or update an existing browser source const string CreateNewChoice = "+ Create new browser source"; List sourceChoices = [CreateNewChoice, .. existingBrowserSourcesInScene]; @@ -4434,7 +4432,7 @@ .. sceneNames.Where(n => n != currentProgramScene).OrderBy(n => n), { UiInfo($"Updating browser source '{sourceName}' settings..."); - // overlay: false — reset to defaults then apply all new settings cleanly + // overlay: false resets to defaults, then applies all new settings cleanly await _obsClient.Inputs.SetInputSettingsAsync( inputName: sourceName, settings: browserSettings, @@ -4461,7 +4459,7 @@ await _obsClient.Inputs.SetInputSettingsAsync( ); RenderKeyValueTable( - $"Browser Source — {(isNewSource ? "Created" : "Updated")}", + $"Browser Source: {(isNewSource ? "Created" : "Updated")}", [ ("Name", sourceName), ("Scene", selectedScene), @@ -4473,7 +4471,7 @@ await _obsClient.Inputs.SetInputSettingsAsync( ("OBS Control Level", "Full (webpage_control_level: 5)"), ("Refresh on Scene Active", "Yes (restart_when_active: true)"), ("Blend Mode", "Normal (OBS_BLEND_NORMAL)"), - ("Blending Method", "sRGB Off — set manually in OBS (not exposed by WebSocket v5)"), + ("Blending Method", "sRGB Off, set manually in OBS (not exposed by WebSocket v5)"), ] ); } diff --git a/ObsWebSocket.Tests/FalsyRequestFieldTests.cs b/ObsWebSocket.Tests/FalsyRequestFieldTests.cs index 588fd91..56fdd3a 100644 --- a/ObsWebSocket.Tests/FalsyRequestFieldTests.cs +++ b/ObsWebSocket.Tests/FalsyRequestFieldTests.cs @@ -51,7 +51,7 @@ public void Serialize_RequiredZeroNumber_KeepsField() public void Serialize_UnsetOptionalField_IsStillOmitted() { // Optional protocol fields are generated as nullable, so they must stay absent - // rather than being sent as explicit nulls — OBS applies its own defaults for + // rather than being sent as explicit nulls. OBS applies its own defaults for // fields that are not present. string json = Json(new CreateSceneItemRequestData(sceneName: "Scene", sourceName: "Src")); Assert.IsFalse(json.Contains("null", StringComparison.Ordinal), json); diff --git a/ObsWebSocket.Tests/StubArrayTests.cs b/ObsWebSocket.Tests/StubArrayTests.cs index ca335bd..35f2bc3 100644 --- a/ObsWebSocket.Tests/StubArrayTests.cs +++ b/ObsWebSocket.Tests/StubArrayTests.cs @@ -254,12 +254,11 @@ public void MeterItem_ReadAsInputStub_FailsOnTheKindFieldsItNeverSends() StringAssert.Contains(ex.InnerException!.Message, "inputKind"); } - /// /// OBS copies the output dimensions straight out of obs_output_get_width and /// obs_output_get_height, which return uint32_t and are not clamped on the way - /// out. An output that has never started can report a value past — - /// a live OBS 32.2.2 sent 2586032160 for an idle virtual camera — and as an int that + /// out. An output that has never started can report a value past ; a + /// live OBS 32.2.2 sent 2586032160 for an idle virtual camera. As an int that /// took the whole GetOutputList response down, not just the one field. /// [TestMethod] @@ -314,10 +313,11 @@ public void OutputList_HeightAboveInt32Max_RoundTripsOverMsgPack() original, MsgPackMessageSerializer.s_msgPackOptions ); - GetOutputListResponseData read = MessagePackSerializer.Deserialize( - packed, - MsgPackMessageSerializer.s_msgPackOptions - ); + GetOutputListResponseData read = + MessagePackSerializer.Deserialize( + packed, + MsgPackMessageSerializer.s_msgPackOptions + ); Assert.AreEqual(2586032160L, read.Outputs[0].OutputHeight); } @@ -359,7 +359,6 @@ public void Stats_CountersAboveInt32Max_ReadOverJson() Assert.AreEqual(6000000000L, read.WebSocketSessionOutgoingMessages); } - /// /// A canvas-scoped scene list has no index to report, so OBS sends sceneIndex as null /// rather than omitting it. As a non-nullable member that failed the whole response.