using TeamsISO.Engine.Domain; namespace TeamsISO.Engine.Discovery; /// /// 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. /// public static class NdiSourceParser { /// /// Brand prefixes we recognize, ordered LONGEST FIRST so that "MS Teams" /// is matched before the shorter "Teams" substring it contains. /// private static readonly string[] BrandPrefixes = { "Microsoft Teams", "MS Teams", "Teams" }; /// /// Reserved suffix tokens that classify a source as a non-participant kind. /// Includes both legacy spellings ("Audio") and current ("Audio Mix"). /// private static readonly Dictionary 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 " (" 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 " - ". 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; } }