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/Emitter.HandleOverloads.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs new file mode 100644 index 0000000..e653f9c --- /dev/null +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs @@ -0,0 +1,388 @@ +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(); + + 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, + methodNames[request.RequestType] + ); + 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) + ); + } + + /// + /// 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 + { + "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 shortName + ) + { + string baseName = SanitizeIdentifier(request.RequestType); + string methodName = SanitizeIdentifier(shortName) + "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(" /// "); + 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); + } + + 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}.{baseName}Async("); + 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/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/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/NumericFieldTable.cs b/ObsWebSocket.Codegen.Tasks/Generation/NumericFieldTable.cs index 1f3dee3..6a54e48 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.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs index 3693ac3..4b636c1 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs @@ -43,6 +43,8 @@ IReadOnlyList Diagnostics Emitter.GenerateResponseDtos(context, protocol); 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/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/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs new file mode 100644 index 0000000..01e0823 --- /dev/null +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs @@ -0,0 +1,1703 @@ +// +#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. + /// + /// + /// 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 CreateAsync( + 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. + /// + /// + /// 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 GetAsync( + 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. + /// + /// + /// 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 RemoveAsync( + 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. + /// + /// + /// 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 SetEnabledAsync( + 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. + /// + /// + /// 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 SetIndexAsync( + 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). + /// + /// + /// 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 SetNameAsync( + 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. + /// + /// + /// 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 SetSettingsAsync( + 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. + /// + /// + /// 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 GetAudioBalanceAsync( + 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` + /// + /// + /// 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 GetAudioMonitorTypeAsync( + 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! + /// + /// + /// 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 GetAudioSyncOffsetAsync( + 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. + /// + /// + /// 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 GetAudioTracksAsync( + 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. + /// + /// + /// 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 GetDeinterlaceFieldOrderAsync( + 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. + /// + /// + /// 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 GetDeinterlaceModeAsync( + 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. + /// + /// + /// 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 GetMuteAsync( + 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. + /// + /// + /// 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 GetPropertiesListPropertyItemsAsync( + 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`. + /// + /// + /// 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 GetSettingsAsync( + 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. + /// + /// + /// 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 GetVolumeAsync( + 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` + /// + /// + /// 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 GetMediaStatusAsync( + 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. + /// + /// + /// 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 OffsetMediaCursorAsync( + 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. + /// + /// + /// 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 OpenFiltersDialogAsync( + 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. + /// + /// + /// 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 OpenInteractDialogAsync( + 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. + /// + /// + /// 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 OpenPropertiesDialogAsync( + 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. + /// + /// + /// 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 PressPropertiesButtonAsync( + 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. + /// + /// + /// 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 RemoveAsync( + 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. + /// + /// + /// 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 SetAudioBalanceAsync( + 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. + /// + /// + /// 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 SetAudioMonitorTypeAsync( + 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. + /// + /// + /// 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 SetAudioSyncOffsetAsync( + 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. + /// + /// + /// 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 SetAudioTracksAsync( + 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. + /// + /// + /// 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 SetDeinterlaceFieldOrderAsync( + 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. + /// + /// + /// 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 SetDeinterlaceModeAsync( + 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. + /// + /// + /// 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 SetMuteAsync( + 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). + /// + /// + /// 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 SetNameAsync( + 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. + /// + /// + /// 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 SetSettingsAsync( + 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. + /// + /// + /// 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 SetVolumeAsync( + 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. + /// + /// + /// 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 SetMediaCursorAsync( + 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. + /// + /// + /// 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 ToggleMuteAsync( + 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. + /// + /// + /// 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 TriggerMediaActionAsync( + 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. + /// + /// + /// 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 + /// 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 + /// + /// + /// 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 CreateItemAsync( + 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 + /// + /// + /// 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 GetGroupItemListAsync( + 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 + /// + /// + /// 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 GetItemIdAsync( + 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 + /// + /// + /// 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 GetItemListAsync( + 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. + /// + /// + /// 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 GetSceneTransitionOverrideAsync( + 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. + /// + /// + /// 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 RemoveAsync( + 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. + /// + /// + /// 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 SetCurrentPreviewAsync( + CancellationToken cancellationToken = default + ) => + client.Scenes.SetCurrentPreviewSceneAsync( + new ObsWebSocket.Core.Protocol.Requests.SetCurrentPreviewSceneRequestData( + sceneName: handle.Name, + sceneUuid: handle.Uuid + ), + cancellationToken + ); + + /// + /// 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 SetCurrentProgramAsync( + 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). + /// + /// + /// 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 SetNameAsync( + 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. + /// + /// + /// 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 SetSceneTransitionOverrideAsync( + string? transitionName = null, + long? 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 + /// + /// + /// 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 DuplicateAsync( + 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 + /// + /// + /// 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 GetBlendModeAsync( + 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 + /// + /// + /// 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 GetEnabledAsync( + 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 + /// + /// + /// 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 GetIndexAsync( + 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 + /// + /// + /// 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 GetLockedAsync( + 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. + /// + /// + /// 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 GetSourceAsync( + 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 + /// + /// + /// 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 GetTransformAsync( + 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 + /// + /// + /// 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 RemoveAsync( + 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 + /// + /// + /// 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 SetBlendModeAsync( + 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 + /// + /// + /// 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 SetEnabledAsync( + 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 + /// + /// + /// 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 SetIndexAsync( + 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 + /// + /// + /// 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 SetLockedAsync( + 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. + /// + /// + /// 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 SetTransformAsync( + 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.** + /// + /// + /// 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 GetActiveAsync( + 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. + /// + /// + /// 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 GetFilterListAsync( + 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.** + /// + /// + /// 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 GetScreenshotAsync( + 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. + /// + /// + /// 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 OpenProjectorAsync( + 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.** + /// + /// + /// 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 + /// 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 SaveScreenshotAsync( + 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.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.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..d862b25 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 @@ -155,20 +155,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/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/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..1975adb --- /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(long 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, long sceneItemId) + { + Scene = scene; + SceneItemId = sceneItemId; + } + + /// The scene the item lives in. + public SceneHandle Scene { get; } + + /// The numeric id OBS assigned the item. + public long SceneItemId { get; } + + /// Builds a handle for an id already known. + public static SceneItemHandle For(SceneHandle scene, long 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/ObsOperationsNavigation.cs b/ObsWebSocket.Core/Handles/ObsOperationsNavigation.cs new file mode 100644 index 0000000..397b577 --- /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(long 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.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.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.Core/Protocol/Common/StubTypes.cs b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs index a46b78c..dd441b2 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. @@ -368,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] @@ -454,11 +472,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 +486,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. @@ -516,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] @@ -536,7 +559,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")] @@ -563,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")] @@ -652,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] @@ -726,20 +795,37 @@ 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; } - - /// Output settings. - [JsonPropertyName("outputSettings")] - [Key("outputSettings")] - public JsonElement? OutputSettings { get; init; } + public required long OutputHeight { 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] diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 18fce6c..19afeac 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,14 @@ 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 +525,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 +538,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 +558,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) @@ -1115,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) @@ -1143,9 +1162,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 +1185,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 +1384,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 +2232,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 +2446,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 +2941,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 = @@ -3013,6 +3095,147 @@ 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; + SceneOperations resolved = await client + .Scene(byName) + .ResolveAsync(cancellationToken) + .ConfigureAwait(false); + SceneHandle byUuid = resolved.Handle; + + GetSceneItemListResponseData viaName = await client + .Scene(byName) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + GetSceneItemListResponseData viaUuid = await client + .Scene(byUuid) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + + string renamed = sceneName + "_renamed"; + await client + .Scene(byUuid) + .SetNameAsync(renamed, cancellationToken) + .ConfigureAwait(false); + + bool uuidStillWorks; + try + { + _ = await client + .Scene(byUuid) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + uuidStillWorks = true; + } + catch (ObsWebSocketRequestException) + { + uuidStillWorks = false; + } + + bool nameNowMisses; + try + { + _ = await client + .Scene(byName) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + nameNowMisses = false; + } + catch (ObsWebSocketRequestException) + { + nameNowMisses = true; + } + + await client + .Scene(byUuid) + .SetNameAsync(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. + SceneItemOperations item = await client + .Scene(sceneName) + .ItemAsync(inputName, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + GetSceneItemEnabledResponseData enabled = await item.GetEnabledAsync( + cancellationToken + ) + .ConfigureAwait(false); + + // Navigating back up reaches the scene the item is in. + GetSceneItemListResponseData siblings = await item + .Scene.GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + + return ( + item.Handle.SceneItemId >= 0 && siblings.SceneItems.Count > 0, + $"'{inputName}' is item {item.Handle.SceneItemId}, enabled " + + $"{enabled.SceneItemEnabled}, among {siblings.SceneItems.Count} " + + "in its scene" + ); + } + ) + .ConfigureAwait(false) + ); + results.Add( await TrySettingsCheckAsync( "Canvases category", @@ -3708,7 +3931,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 @@ -3726,6 +3949,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..."); @@ -4042,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]; @@ -4104,13 +4397,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, @@ -4126,14 +4425,14 @@ .. 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 { 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, @@ -4141,19 +4440,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. @@ -4165,7 +4459,7 @@ await _obsClient.SceneItems.SetSceneItemBlendModeAsync( ); RenderKeyValueTable( - $"Browser Source — {(isNewSource ? "Created" : "Updated")}", + $"Browser Source: {(isNewSource ? "Created" : "Updated")}", [ ("Name", sourceName), ("Scene", selectedScene), @@ -4177,7 +4471,7 @@ await _obsClient.SceneItems.SetSceneItemBlendModeAsync( ("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)"), ] ); } @@ -4195,9 +4489,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]"), @@ -4213,11 +4511,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]"), @@ -4225,7 +4523,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"), @@ -4312,7 +4612,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 @@ -4962,7 +5262,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( @@ -5058,7 +5358,7 @@ await Probe( ) .ConfigureAwait(false); - int? addedItemId = null; + long? addedItemId = null; try { CreateSceneItemResponseData added = await client @@ -5075,7 +5375,7 @@ await Probe( declined.Add($"CreateSceneItem ({ex.StatusCode})"); } - int? duplicatedItemId = null; + long? duplicatedItemId = null; try { DuplicateSceneItemResponseData duplicated = await client @@ -5094,7 +5394,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", @@ -5664,6 +5964,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 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/HandleOperationTests.cs b/ObsWebSocket.Tests/HandleOperationTests.cs new file mode 100644 index 0000000..b1b2a93 --- /dev/null +++ b/ObsWebSocket.Tests/HandleOperationTests.cs @@ -0,0 +1,190 @@ +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").SetCurrentProgramAsync(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).SetCurrentProgramAsync(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")).GetItemListAsync(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)).SetEnabledAsync(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")).SetEnabledAsync(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)) + .DuplicateAsync(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").ToggleMuteAsync(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; + } +} diff --git a/ObsWebSocket.Tests/HandleTests.cs b/ObsWebSocket.Tests/HandleTests.cs new file mode 100644 index 0000000..c946d18 --- /dev/null +++ b/ObsWebSocket.Tests/HandleTests.cs @@ -0,0 +1,239 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Events; +using ObsWebSocket.Core.Protocol.Responses; + +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() + ); + } + + /// + /// 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); + } +} 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/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/ObsWebSocket.Tests/StubArrayTests.cs b/ObsWebSocket.Tests/StubArrayTests.cs index 7092e6e..35f2bc3 100644 --- a/ObsWebSocket.Tests/StubArrayTests.cs +++ b/ObsWebSocket.Tests/StubArrayTests.cs @@ -254,6 +254,178 @@ 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. 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 diff --git a/README.md b/README.md index 1b35124..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,102 +67,188 @@ public sealed class Worker(ObsWebSocketClient client) : BackgroundService } ``` -## Everything is grouped by category +## Three ways to call OBS + +| | Example | Use it for | +|---|---|---| +| [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 forwards to the one below it, so they mix freely. + +## 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: +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. -## The helper set +## Handles + +Most OBS requests identify their target by name or by uuid. A handle carries that identity so it is +not repeated on every call. A string is a name, a `Guid` is a uuid: + +```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 the requests +the protocol defines for that kind of thing, with the entity dropped from the method name: +`SetSceneItemEnabled` is `SetEnabledAsync` on a scene item, `GetInputMute` is `GetMuteAsync` on an +input. The protocol name is in the XML docs and on the category group. + +Requests that are not about a particular thing, such as `GetVersion`, `GetStats` and the record and +stream controls, are on their group only. + +### Names and uuids + +A name works, but breaks if the thing is renamed. Resolving a name to a uuid costs one round trip: + +```csharp +SceneOperations intro = await client.Scene("Intro").ResolveAsync(ct); +// intro.Handle.IsResolved is true, and a rename no longer affects it +``` + +The protocol has no lookup for a single uuid, so this reads the scene list. When the name is not +found, the exception lists the names that were: + +``` +ObsWebSocketResourceNotFoundException: No scene named 'Intor'. Available: 'Intro', 'Gameplay', 'BRB'. +``` + +### Handles from events and responses + +Events and responses that carry a uuid expose a handle for it, so acting on one needs no lookup: + +```csharp +client.Scenes.CurrentProgramSceneChanged += async (_, e) => + await client.Scene(e.EventData.Scene).GetItemListAsync(); + +CreateSceneResponseData created = await client.Scenes.CreateSceneAsync(new("Intro"), ct); +await client.Scene(created.Scene).SetCurrentProgramAsync(ct); +``` + +### Scene items -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. +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: -**Settings read and write** +```csharp +SceneItemOperations logo = await client.Scene("Intro").ItemAsync("Logo", cancellationToken: ct); +await logo.SetEnabledAsync(false, ct); +await logo.Scene.GetItemListAsync(ct); + +await client.Scene("Intro").Item(3).SetIndexAsync(0, ct); // an id needs no lookup +``` + +`Item(long)` and `Filter(string)` send nothing, since an id and a filter name are the whole +identity. + +### Canvases + +Canvas-scoped requests take a uuid; `canvasName` appears only in `GetCanvasList`. Resolve a canvas +by name to use it: + +```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`. A canvas scopes a name only, +so a resolved handle drops it. + +## Helpers + +Each group carries helpers for things that otherwise take several calls or a lookup. They are +hand-written, so they are on the group rather than on a handle. + +Typed settings helpers have two overloads: an implicit one for library-registered types, and an +explicit one taking a `JsonTypeInfo` for your own types. Use the explicit overload under +Native AOT. + +**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)) @@ -171,26 +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}"); ``` -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. -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); @@ -202,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); @@ -244,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))] @@ -263,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 @@ -295,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. +A request type may appear several times in one batch; each reference resolves to its own result. +`Sleep` is valid only inside a batch. -`Sleep` is only valid inside a batch, and pairs with `SerialRealtime` to pace a sequence. - -`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()) @@ -313,176 +392,110 @@ 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 - -`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). +### Parallel batches -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`. +`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). -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: +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, 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. - -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(); +int failureCount = results.GetFailures().Count(); ``` -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` reaches every payload, and `GetData` reads one without consulting the label, so a +batch where every request returns the same type is fully recoverable. -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: +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); 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 +## Raw requests -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 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. -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()` 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 -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; ``` -**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) => @@ -492,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", }; }; @@ -500,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 @@ -520,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: @@ -539,9 +546,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 Register clients by name and resolve them with `[FromKeyedServices]`: @@ -559,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 { @@ -581,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; @@ -593,20 +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`. -Requests return their response data non-nullable; a successful request that carries no payload -raises `ObsWebSocketException` rather than handing back null. - ## 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") @@ -614,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 @@ -629,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**: 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` +- 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. -`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. +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 @@ -665,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