using System.IO; using System.Text.Json; using System.Text.Json.Nodes; namespace TeamsISO.App.Services; /// /// Reads and writes NDI Access Manager's per-user config at /// %APPDATA%\NDI\ndi-config.v1.json. This file controls global NDI behavior for /// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP /// transport toggles, allowed adapters, etc. NDI applications read it on startup, so /// changes here only take effect after restarting the affected app (Teams, OBS, etc.). /// /// We use it to implement the "transcoder topology" requested by the user: pin Teams' /// raw at-source-resolution NDI broadcasts to a private group (teamsiso-input) so /// they don't pollute the production network, while TeamsISO's own clean normalized ISO /// outputs continue to broadcast on the standard Public group that downstream /// switchers and recorders default to. /// /// The shape of ndi-config.v1.json is documented in the NDI 6 SDK headers; we work in /// terms of trees so we don't clobber unrelated keys (e.g. RUDP /// settings the user may have customized in Access Manager). /// public static class NdiAccessManagerConfig { /// /// Path to the NDI Access Manager config. %APPDATA%\NDI\ndi-config.v1.json. /// public static string ConfigPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "NDI", "ndi-config.v1.json"); /// /// Default name of the private group used for the transcoder topology. /// Matches the convention referenced in the NDI Network settings UI. /// public const string TranscoderInputGroup = "teamsiso-input"; /// /// Result of an apply attempt. indicates the file was /// written or already had the desired groups. is set /// to the path of the saved-aside copy of the prior config (when one existed), /// so the user can revert if they don't like the change. /// public sealed record ApplyResult( bool Success, string ConfigPath, string? BackupPath, string? ErrorMessage); /// /// Configures the machine-wide NDI groups so: /// /// All local senders (Teams, anything else) broadcast on /// only — i.e. the private input group. /// All local receivers see both and /// public so TeamsISO can discover Teams' sources AND any /// standard public sources from elsewhere on the network. /// /// TeamsISO's own per-pipeline OutputGroups still overrides the per-machine /// default at the sender level, so its normalized ISO outputs go on Public. /// /// Private group name for Teams' raw broadcasts. public static ApplyResult ApplyTranscoderTopology(string senderGroup = TranscoderInputGroup) { try { var root = LoadOrCreate(); var ndi = EnsureObject(root, "ndi"); var groups = EnsureObject(ndi, "groups"); groups["send"] = new JsonArray(senderGroup); groups["recv"] = new JsonArray("public", senderGroup); var backupPath = WriteWithBackup(root); return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null); } catch (Exception ex) { return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message); } } /// /// Restores defaults: senders on public, receivers on public. /// Equivalent to undoing . /// public static ApplyResult RestoreDefaults() { try { var root = LoadOrCreate(); var ndi = EnsureObject(root, "ndi"); var groups = EnsureObject(ndi, "groups"); groups["send"] = new JsonArray("public"); groups["recv"] = new JsonArray("public"); var backupPath = WriteWithBackup(root); return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null); } catch (Exception ex) { return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message); } } /// /// Reads the current sender / receiver group lists, or null if the config doesn't /// exist yet (NDI Access Manager has never been opened on this machine). /// public static (IReadOnlyList? Send, IReadOnlyList? Recv) ReadCurrentGroups() { if (!File.Exists(ConfigPath)) return (null, null); try { using var stream = File.OpenRead(ConfigPath); var root = JsonNode.Parse(stream); var groups = root?["ndi"]?["groups"]; return ( AsStringList(groups?["send"]), AsStringList(groups?["recv"])); } catch { return (null, null); } } private static IReadOnlyList? AsStringList(JsonNode? node) => node is JsonArray arr ? arr.Select(n => n?.GetValue() ?? string.Empty).ToArray() : null; private static JsonObject LoadOrCreate() { if (File.Exists(ConfigPath)) { using var stream = File.OpenRead(ConfigPath); var existing = JsonNode.Parse(stream) as JsonObject; if (existing is not null) return existing; } return new JsonObject(); } private static JsonObject EnsureObject(JsonNode? parent, string key) { if (parent is not JsonObject obj) throw new InvalidOperationException($"Cannot ensure key '{key}' on a non-object parent."); if (obj[key] is JsonObject existing) return existing; var fresh = new JsonObject(); obj[key] = fresh; return fresh; } /// /// Writes the config atomically (temp file + replace) and saves a backup of the /// prior contents next to the original with a timestamp suffix. Returns the /// backup path if a prior file existed; null on first-write. /// private static string? WriteWithBackup(JsonNode root) { var dir = Path.GetDirectoryName(ConfigPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); string? backupPath = null; if (File.Exists(ConfigPath)) { var stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); backupPath = ConfigPath + $".bak-{stamp}"; File.Copy(ConfigPath, backupPath, overwrite: true); } var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); var tempPath = ConfigPath + ".tmp"; File.WriteAllText(tempPath, json); if (File.Exists(ConfigPath)) File.Replace(tempPath, ConfigPath, destinationBackupFileName: null); else File.Move(tempPath, ConfigPath); return backupPath; } }