teamsiso/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs
Zac Gaetano ca124540a7 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.
2026-05-07 23:33:43 -04:00

100 lines
4.2 KiB
C#

using TeamsISO.Engine.Domain;
namespace TeamsISO.Engine.Discovery;
/// <summary>
/// Parses NDI source strings emitted by Microsoft Teams.
///
/// Teams has shipped two on-the-wire naming conventions for its NDI broadcast
/// outputs. Both are recognized.
///
/// Legacy (older Teams desktop):
/// "MACHINE (Teams)" — auto-mixed active-speaker output
/// "MACHINE (Teams Audio)" — audio-only mix
/// "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>
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)
{
if (string.IsNullOrWhiteSpace(fullName)) return null;
if (!fullName.EndsWith(')')) return null;
// Locate "<MACHINE> (<BRAND>" by scanning for " (BRAND" with the longest
// brand first. Anchoring this way (rather than to the last '(') means
// 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)
{
var needle = " (" + candidate;
var i = fullName.IndexOf(needle, StringComparison.Ordinal);
if (i > 0)
{
openParen = i + 1;
brand = candidate;
break;
}
}
if (openParen < 0 || brand is null) return null;
var machine = fullName[..openParen].TrimEnd();
if (machine.Length == 0) return null;
// inner is the text between '(' and the trailing ')'.
var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim();
if (!inner.StartsWith(brand, StringComparison.Ordinal)) return null;
var rest = inner[brand.Length..].TrimStart();
// Legacy "MACHINE (Teams)" — bare brand, no suffix → active speaker.
if (rest.Length == 0)
return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null);
// Legacy: brand followed by a reserved word with no dash, e.g. "Teams Audio".
if (ReservedSuffixes.TryGetValue(rest, out var legacyKind))
return new NdiSource(fullName, machine, legacyKind, DisplayName: null);
// Current: brand followed by " - <suffix>".
if (rest.StartsWith('-'))
{
var afterDash = rest[1..].TrimStart();
if (afterDash.Length == 0) return null;
if (ReservedSuffixes.TryGetValue(afterDash, out var taggedKind))
return new NdiSource(fullName, machine, taggedKind, DisplayName: null);
return new NdiSource(fullName, machine, NdiSourceKind.Participant, afterDash);
}
return null;
}
}