From aaf3184a8e917b1e27f7e30d28cf7db540b0c49b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 7 May 2026 15:11:00 +0000 Subject: [PATCH] feat(discovery): add NdiSource record and Teams source string parser --- .../Discovery/NdiSourceParser.cs | 70 +++++++++++++++++++ src/TeamsISO.Engine/Domain/NdiSource.cs | 10 +++ .../Domain/NdiSourceParserTests.cs | 46 ++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/TeamsISO.Engine/Discovery/NdiSourceParser.cs create mode 100644 src/TeamsISO.Engine/Domain/NdiSource.cs create mode 100644 src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs diff --git a/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs new file mode 100644 index 0000000..565ac84 --- /dev/null +++ b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs @@ -0,0 +1,70 @@ +using TeamsISO.Engine.Domain; + +namespace TeamsISO.Engine.Discovery; + +/// +/// Parses NDI source strings emitted by Microsoft Teams. +/// +/// Examples Teams emits: +/// "MACHINE (Teams - Display Name)" +/// "MACHINE (Teams)" — auto-mixed active speaker +/// "MACHINE (Teams Audio)" — audio-only mix +/// "MACHINE (Teams Screen Share)" — screen share +/// +public static class NdiSourceParser +{ + public static NdiSource? Parse(string fullName) + { + if (string.IsNullOrWhiteSpace(fullName)) + return null; + + // Find the last '(' so machine names can themselves contain parens. + var openParen = fullName.LastIndexOf('('); + if (openParen <= 0 || !fullName.EndsWith(')')) + { + // Try outer parens for cases like "MACHINE (Teams - Smith, Bob (PM))" + // by walking from the first '(' that opens a "Teams" inner. + var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal); + if (firstParen <= 0) + return null; + openParen = firstParen + 1; + if (!fullName.EndsWith(')')) + return null; + } + else + { + // Re-anchor to the first " (Teams" if present, so display names containing + // their own parens (e.g. "Smith, Bob (PM)") don't get truncated. + var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal); + if (firstParen > 0) + openParen = firstParen + 1; + } + + var machine = fullName[..openParen].TrimEnd(); + if (machine.Length == 0) + return null; + + var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim(); + + if (!inner.StartsWith("Teams", StringComparison.Ordinal)) + return null; + + if (inner == "Teams") + return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null); + if (inner == "Teams Audio") + return new NdiSource(fullName, machine, NdiSourceKind.Audio, DisplayName: null); + if (inner == "Teams Screen Share") + return new NdiSource(fullName, machine, NdiSourceKind.ScreenShare, DisplayName: null); + + const string prefix = "Teams - "; + if (inner.StartsWith(prefix, StringComparison.Ordinal)) + { + var display = inner[prefix.Length..].Trim(); + if (display.Length == 0) + return null; + return new NdiSource(fullName, machine, NdiSourceKind.Participant, display); + } + + return null; + } +} diff --git a/src/TeamsISO.Engine/Domain/NdiSource.cs b/src/TeamsISO.Engine/Domain/NdiSource.cs new file mode 100644 index 0000000..2394db0 --- /dev/null +++ b/src/TeamsISO.Engine/Domain/NdiSource.cs @@ -0,0 +1,10 @@ +namespace TeamsISO.Engine.Domain; + +/// +/// Raw discovery record parsed from an NDI source string emitted by Microsoft Teams. +/// +public sealed record NdiSource( + string FullName, + string MachineName, + NdiSourceKind Kind, + string? DisplayName); diff --git a/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs new file mode 100644 index 0000000..8268d06 --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs @@ -0,0 +1,46 @@ +using TeamsISO.Engine.Discovery; +using TeamsISO.Engine.Domain; + +namespace TeamsISO.Engine.Tests.Domain; + +public class NdiSourceParserTests +{ + [Theory] + [InlineData("WORKSTATION-01 (Teams - Jane Doe)", "WORKSTATION-01", NdiSourceKind.Participant, "Jane Doe")] + [InlineData("PROD-PC (Teams - Élise O'Connor)", "PROD-PC", NdiSourceKind.Participant, "Élise O'Connor")] + [InlineData("HOST (Teams - Smith, Bob (PM))", "HOST", NdiSourceKind.Participant, "Smith, Bob (PM)")] + public void Parse_Participant_ExtractsMachineAndDisplayName( + string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay) + { + var result = NdiSourceParser.Parse(fullName); + + result.Should().NotBeNull(); + result!.MachineName.Should().Be(expectedMachine); + result.Kind.Should().Be(expectedKind); + result.DisplayName.Should().Be(expectedDisplay); + result.FullName.Should().Be(fullName); + } + + [Theory] + [InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)] + [InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)] + [InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)] + public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind) + { + var result = NdiSourceParser.Parse(fullName); + + result.Should().NotBeNull(); + result!.Kind.Should().Be(expectedKind); + result.DisplayName.Should().BeNull(); + } + + [Theory] + [InlineData("Plain NDI Source")] + [InlineData("HOST (Some Other Software)")] + [InlineData("(Teams - No Machine)")] + public void Parse_NonTeamsSource_ReturnsNull(string fullName) + { + var result = NdiSourceParser.Parse(fullName); + result.Should().BeNull(); + } +}