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;
///
/// One-call shape for the control surface's GET /topology: returns
/// the current sender + receiver group lists alongside a computed
/// mode string. "hidden" when senders are confined to the private
/// transcoder-input group (raw Teams sources invisible on the LAN);
/// "public" when senders are on the default group; "unknown" when the
/// config file is missing or malformed (treated by callers as "public"
/// because NDI's runtime defaults to public when no config is present).
///
public static (string Mode, IReadOnlyList Senders, IReadOnlyList Receivers) ReadCurrent()
{
var (send, recv) = ReadCurrentGroups();
var senders = send ?? Array.Empty();
var receivers = recv ?? Array.Empty();
string mode;
if (send is null)
{
mode = "unknown";
}
else if (send.Count == 1 && string.Equals(send[0], TranscoderInputGroup, StringComparison.OrdinalIgnoreCase))
{
mode = "hidden";
}
else
{
mode = "public";
}
return (mode, senders, receivers);
}
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;
}
}