fix(parser): accept 'MS Teams' brand prefix from current Teams NDI broadcasts
The new Microsoft Teams desktop client (observed against a live meeting on Teams 26106.1906.4665.7308) emits NDI source strings of the form
WOOGLIN (MS Teams - Brendon Power)
WOOGLIN (MS Teams - (Local))
WOOGLIN (MS Teams - Active Speaker)
rather than the legacy 'MACHINE (Teams - ...)' shape NdiSourceParser was written to. As a result every Teams source was rejected and TeamsISO showed zero participants in real meetings.
Refactor the parser to recognize 'Teams', 'MS Teams', and (defensively) 'Microsoft Teams' as brand prefixes — longest first so 'MS Teams' isn't shadowed. Also recognize reserved suffix tokens after a dash ('Active Speaker' / 'Audio' / 'Audio Mix' / 'Screen Share') so the new active-speaker output is correctly classified as ActiveSpeaker rather than misread as a participant named 'Active Speaker'.
Tests: kept all legacy cases, added MS Teams + Microsoft Teams variants and the new dash-prefixed reserved-suffix cases. 69/69 unit tests passing; verified end-to-end against a live Teams meeting where TeamsISO.exe now shows '(Local)' and 'Brendon Power' in the Participants DataGrid.
This commit is contained in:
parent
d90ebb826f
commit
ca124540a7
2 changed files with 81 additions and 39 deletions
|
|
@ -5,64 +5,94 @@ namespace TeamsISO.Engine.Discovery;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses NDI source strings emitted by Microsoft Teams.
|
/// Parses NDI source strings emitted by Microsoft Teams.
|
||||||
///
|
///
|
||||||
/// Examples Teams emits:
|
/// Teams has shipped two on-the-wire naming conventions for its NDI broadcast
|
||||||
/// "MACHINE (Teams - Display Name)"
|
/// outputs. Both are recognized.
|
||||||
/// "MACHINE (Teams)" — auto-mixed active speaker
|
///
|
||||||
|
/// Legacy (older Teams desktop):
|
||||||
|
/// "MACHINE (Teams)" — auto-mixed active-speaker output
|
||||||
/// "MACHINE (Teams Audio)" — audio-only mix
|
/// "MACHINE (Teams Audio)" — audio-only mix
|
||||||
/// "MACHINE (Teams Screen Share)" — screen share
|
/// "MACHINE (Teams Screen Share)" — screen share
|
||||||
|
/// "MACHINE (Teams - Display Name)" — per-participant
|
||||||
|
///
|
||||||
|
/// Current (the new Microsoft Teams desktop client, observed 2026):
|
||||||
|
/// "MACHINE (MS Teams - Active Speaker)" — auto-mixed active-speaker output
|
||||||
|
/// "MACHINE (MS Teams - Audio Mix)" — audio-only mix
|
||||||
|
/// "MACHINE (MS Teams - Screen Share)" — screen share
|
||||||
|
/// "MACHINE (MS Teams - (Local))" — the local user's own preview
|
||||||
|
/// "MACHINE (MS Teams - Display Name)" — per-participant
|
||||||
|
///
|
||||||
|
/// "Microsoft Teams" is also accepted as a defensive future-proof brand prefix.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NdiSourceParser
|
public static class NdiSourceParser
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Brand prefixes we recognize, ordered LONGEST FIRST so that "MS Teams"
|
||||||
|
/// is matched before the shorter "Teams" substring it contains.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] BrandPrefixes = { "Microsoft Teams", "MS Teams", "Teams" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reserved suffix tokens that classify a source as a non-participant kind.
|
||||||
|
/// Includes both legacy spellings ("Audio") and current ("Audio Mix").
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, NdiSourceKind> ReservedSuffixes = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["Active Speaker"] = NdiSourceKind.ActiveSpeaker,
|
||||||
|
["Audio"] = NdiSourceKind.Audio,
|
||||||
|
["Audio Mix"] = NdiSourceKind.Audio,
|
||||||
|
["Screen Share"] = NdiSourceKind.ScreenShare,
|
||||||
|
};
|
||||||
|
|
||||||
public static NdiSource? Parse(string fullName)
|
public static NdiSource? Parse(string fullName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fullName))
|
if (string.IsNullOrWhiteSpace(fullName)) return null;
|
||||||
return null;
|
if (!fullName.EndsWith(')')) return null;
|
||||||
|
|
||||||
// Find the last '(' so machine names can themselves contain parens.
|
// Locate "<MACHINE> (<BRAND>" by scanning for " (BRAND" with the longest
|
||||||
var openParen = fullName.LastIndexOf('(');
|
// brand first. Anchoring this way (rather than to the last '(') means
|
||||||
if (openParen <= 0 || !fullName.EndsWith(')'))
|
// machine names that themselves contain parens won't truncate the boundary,
|
||||||
|
// and display names that contain parens (e.g. "(Local)") survive intact
|
||||||
|
// because we extract them by string length, not by re-scanning for ')'.
|
||||||
|
var openParen = -1;
|
||||||
|
string? brand = null;
|
||||||
|
foreach (var candidate in BrandPrefixes)
|
||||||
{
|
{
|
||||||
// Try outer parens for cases like "MACHINE (Teams - Smith, Bob (PM))"
|
var needle = " (" + candidate;
|
||||||
// by walking from the first '(' that opens a "Teams" inner.
|
var i = fullName.IndexOf(needle, StringComparison.Ordinal);
|
||||||
var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal);
|
if (i > 0)
|
||||||
if (firstParen <= 0)
|
{
|
||||||
return null;
|
openParen = i + 1;
|
||||||
openParen = firstParen + 1;
|
brand = candidate;
|
||||||
if (!fullName.EndsWith(')'))
|
break;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (openParen < 0 || brand is null) return null;
|
||||||
|
|
||||||
var machine = fullName[..openParen].TrimEnd();
|
var machine = fullName[..openParen].TrimEnd();
|
||||||
if (machine.Length == 0)
|
if (machine.Length == 0) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
|
// inner is the text between '(' and the trailing ')'.
|
||||||
var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim();
|
var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim();
|
||||||
|
if (!inner.StartsWith(brand, StringComparison.Ordinal)) return null;
|
||||||
|
|
||||||
if (!inner.StartsWith("Teams", StringComparison.Ordinal))
|
var rest = inner[brand.Length..].TrimStart();
|
||||||
return null;
|
|
||||||
|
|
||||||
if (inner == "Teams")
|
// Legacy "MACHINE (Teams)" — bare brand, no suffix → active speaker.
|
||||||
|
if (rest.Length == 0)
|
||||||
return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null);
|
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 - ";
|
// Legacy: brand followed by a reserved word with no dash, e.g. "Teams Audio".
|
||||||
if (inner.StartsWith(prefix, StringComparison.Ordinal))
|
if (ReservedSuffixes.TryGetValue(rest, out var legacyKind))
|
||||||
|
return new NdiSource(fullName, machine, legacyKind, DisplayName: null);
|
||||||
|
|
||||||
|
// Current: brand followed by " - <suffix>".
|
||||||
|
if (rest.StartsWith('-'))
|
||||||
{
|
{
|
||||||
var display = inner[prefix.Length..].Trim();
|
var afterDash = rest[1..].TrimStart();
|
||||||
if (display.Length == 0)
|
if (afterDash.Length == 0) return null;
|
||||||
return null;
|
if (ReservedSuffixes.TryGetValue(afterDash, out var taggedKind))
|
||||||
return new NdiSource(fullName, machine, NdiSourceKind.Participant, display);
|
return new NdiSource(fullName, machine, taggedKind, DisplayName: null);
|
||||||
|
return new NdiSource(fullName, machine, NdiSourceKind.Participant, afterDash);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,16 @@ namespace TeamsISO.Engine.Tests.Domain;
|
||||||
public class NdiSourceParserTests
|
public class NdiSourceParserTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Theory]
|
||||||
|
// ----- Legacy "Teams" brand (older Teams desktop) -----
|
||||||
[InlineData("WORKSTATION-01 (Teams - Jane Doe)", "WORKSTATION-01", NdiSourceKind.Participant, "Jane Doe")]
|
[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("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)")]
|
[InlineData("HOST (Teams - Smith, Bob (PM))", "HOST", NdiSourceKind.Participant, "Smith, Bob (PM)")]
|
||||||
|
// ----- Current "MS Teams" brand (the new Teams desktop client) -----
|
||||||
|
[InlineData("WOOGLIN (MS Teams - Brendon Power)", "WOOGLIN", NdiSourceKind.Participant, "Brendon Power")]
|
||||||
|
[InlineData("WOOGLIN (MS Teams - (Local))", "WOOGLIN", NdiSourceKind.Participant, "(Local)")]
|
||||||
|
[InlineData("LAB-PC (MS Teams - Smith, Bob (PM))", "LAB-PC", NdiSourceKind.Participant, "Smith, Bob (PM)")]
|
||||||
|
// ----- Defensive future-proof "Microsoft Teams" brand -----
|
||||||
|
[InlineData("WORKSTATION (Microsoft Teams - Alex Rivera)", "WORKSTATION", NdiSourceKind.Participant, "Alex Rivera")]
|
||||||
public void Parse_Participant_ExtractsMachineAndDisplayName(
|
public void Parse_Participant_ExtractsMachineAndDisplayName(
|
||||||
string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay)
|
string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay)
|
||||||
{
|
{
|
||||||
|
|
@ -22,9 +29,14 @@ public class NdiSourceParserTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
// ----- Legacy "Teams" forms -----
|
||||||
[InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)]
|
[InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)]
|
||||||
[InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)]
|
[InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)]
|
||||||
[InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)]
|
[InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)]
|
||||||
|
// ----- Current "MS Teams" forms (dash-prefixed reserved suffix) -----
|
||||||
|
[InlineData("WOOGLIN (MS Teams - Active Speaker)", NdiSourceKind.ActiveSpeaker)]
|
||||||
|
[InlineData("WOOGLIN (MS Teams - Audio Mix)", NdiSourceKind.Audio)]
|
||||||
|
[InlineData("WOOGLIN (MS Teams - Screen Share)", NdiSourceKind.ScreenShare)]
|
||||||
public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind)
|
public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind)
|
||||||
{
|
{
|
||||||
var result = NdiSourceParser.Parse(fullName);
|
var result = NdiSourceParser.Parse(fullName);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue