using System.IO; using System.Linq; using System.Text; namespace TeamsISO.App.Services; /// /// User-editable template for the NDI source name a participant's ISO is /// published as. Default "{name}" renders the speaker's display name /// directly, which is what downstream switchers want when they key on /// readable identifiers. Operators can override globally to /// "TEAMSISO_{guid}" for the legacy stable-id behavior, or /// "TEAMSISO_{machine}_{name}" when multiple TeamsISO machines feed /// the same NDI network and you want the source name to carry both. /// Per-participant overrides take priority over whatever template is set. /// /// Tokens expanded in : /// {name} participant display name, sanitized (alphanumeric + underscore) /// {guid} first 8 hex chars of the participant's Id, uppercase /// {machine} sanitized PC hostname (Environment.MachineName) /// {timestamp} current local time as yyyyMMdd_HHmmss /// /// Empty-name fallback: if the rendered result is empty/whitespace (e.g. /// template was "{name}" and the participant joined with no display /// name yet), falls back to TEAMSISO_{guid} so /// the NDI sender always has a usable, unique identifier. /// /// Persisted to %LOCALAPPDATA%\TeamsISO\output-name-template.txt. /// public static class OutputNameTemplate { /// /// Default template — renders just the speaker's display name. Was /// "TEAMSISO_{guid}" in pre-v1 builds; switched 2026-05-16 so /// new installs get human-readable source names out of the box. /// public const string DefaultTemplate = "{name}"; /// /// Stable fallback used when the rendered template produces an empty /// string (typically because a participant has no display name yet). /// Mirrors the engine's legacy DefaultOutputName so the NDI sender is /// always uniquely identifiable. /// private const string EmptyNameFallback = "TEAMSISO_{guid}"; private static string TemplatePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TeamsISO", "output-name-template.txt"); /// /// Get the operator's current template, or the shipped default when no /// override has been saved (or the override file is missing/unreadable). /// public static string Get() { try { if (File.Exists(TemplatePath)) { var raw = File.ReadAllText(TemplatePath).Trim(); if (!string.IsNullOrEmpty(raw)) return raw; } } catch { // Disk read failure → fall through to default. The next Set() call // will overwrite cleanly. } return DefaultTemplate; } public static void Set(string template) { try { var dir = Path.GetDirectoryName(TemplatePath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.WriteAllText(TemplatePath, template ?? string.Empty); } catch { // Best-effort persistence; the in-memory value still sticks for // this session. } } /// /// Expand tokens in for a specific participant. /// Result is sanitized into NDI-safe characters: alphanumeric, underscore, /// hyphen, period. NDI spec allows more, but a conservative set keeps /// downstream switchers happy. /// public static string Render(string template, Guid participantId, string displayName) { var safeName = SanitizeForNdi(displayName); var guid = participantId.ToString("N")[..8].ToUpperInvariant(); var machine = SanitizeForNdi(Environment.MachineName); var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd_HHmmss"); var result = template .Replace("{name}", safeName) .Replace("{guid}", guid) .Replace("{machine}", machine) .Replace("{timestamp}", timestamp); // Final sanitize on the rendered result — protects against a template // that includes literal characters NDI doesn't accept. var sanitized = SanitizeForNdi(result); // Empty-name fallback. The default template "{name}" can render to // an unusable result for participants whose DisplayName hasn't been // populated yet (Teams sometimes delivers the displayName a tick // after the participant join event). Two failure modes to catch: // // • DisplayName == "" → "{name}" expands to "" → sanitized "". // • DisplayName == " " → "{name}" expands to "___" because the // sanitizer converts whitespace to underscores. // // Neither is a meaningful NDI source identifier, so we substitute // TEAMSISO_{guid}. The Any(char.IsLetterOrDigit) check covers both // cases — anything without at least one alphanumeric is unusable. // We apply this AFTER token expansion (not on the raw input) so a // template like "PFX_{name}" with empty displayName still works: // it renders to "PFX_" which contains alphanumerics and is left // alone. if (string.IsNullOrWhiteSpace(sanitized) || !sanitized.Any(char.IsLetterOrDigit)) { sanitized = SanitizeForNdi(EmptyNameFallback.Replace("{guid}", guid)); } return sanitized; } private static string SanitizeForNdi(string s) { if (string.IsNullOrEmpty(s)) return string.Empty; var sb = new StringBuilder(s.Length); foreach (var c in s) { if (char.IsLetterOrDigit(c) || c is '_' or '-' or '.') sb.Append(c); else if (char.IsWhiteSpace(c)) sb.Append('_'); // else: skip } return sb.ToString(); } }