dragon-iso/src/TeamsISO.App/Services/NdiAccessManagerConfig.cs
Zac Gaetano 647deec304
Some checks failed
CI / build-and-test (push) Failing after 30s
feat(web): topology + thumbnail endpoints, redesigned /ui control panel
REST additions: GET /topology returns mode (hidden/public/unknown) + sender/receiver group lists. POST /topology/apply confines local senders to teamsiso-input + receivers to public+teamsiso-input. POST /topology/restore returns both to public defaults.

GET /participants/{id}/thumbnail.jpg encodes the latest engine ProcessedFrame as a 192-wide JPEG. 404 when no pipeline is running. Used by the /ui control panel for live preview tiles.

Settings: ControlSurfaceEnabled now persists across sessions via UIPreferences and auto-starts the server on app launch when previously enabled.

/ui control panel rebuilt: live thumbnail per row, topology toggle card with Hide/Restore buttons, removed dead recording marker button, larger layout (920px), participant rows in single card with hover affordances.
2026-05-15 15:06:11 -04:00

210 lines
8.5 KiB
C#

using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace TeamsISO.App.Services;
/// <summary>
/// Reads and writes NDI Access Manager's per-user config at
/// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. 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 (<c>teamsiso-input</c>) so
/// they don't pollute the production network, while TeamsISO's own clean normalized ISO
/// outputs continue to broadcast on the standard <c>Public</c> 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 <see cref="JsonNode"/> trees so we don't clobber unrelated keys (e.g. RUDP
/// settings the user may have customized in Access Manager).
/// </summary>
public static class NdiAccessManagerConfig
{
/// <summary>
/// Path to the NDI Access Manager config. <c>%APPDATA%\NDI\ndi-config.v1.json</c>.
/// </summary>
public static string ConfigPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"NDI",
"ndi-config.v1.json");
/// <summary>
/// Default name of the private group used for the transcoder topology.
/// Matches the convention referenced in the NDI Network settings UI.
/// </summary>
public const string TranscoderInputGroup = "teamsiso-input";
/// <summary>
/// Result of an apply attempt. <see cref="Success"/> indicates the file was
/// written or already had the desired groups. <see cref="BackupPath"/> 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.
/// </summary>
public sealed record ApplyResult(
bool Success,
string ConfigPath,
string? BackupPath,
string? ErrorMessage);
/// <summary>
/// Configures the machine-wide NDI groups so:
/// <list type="bullet">
/// <item>All local senders (Teams, anything else) broadcast on
/// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
/// <item>All local receivers see both <paramref name="senderGroup"/> and
/// <c>public</c> so TeamsISO can discover Teams' sources AND any
/// standard public sources from elsewhere on the network.</item>
/// </list>
/// TeamsISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
/// default at the sender level, so its normalized ISO outputs go on Public.
/// </summary>
/// <param name="senderGroup">Private group name for Teams' raw broadcasts.</param>
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);
}
}
/// <summary>
/// Restores defaults: senders on <c>public</c>, receivers on <c>public</c>.
/// Equivalent to undoing <see cref="ApplyTranscoderTopology"/>.
/// </summary>
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);
}
}
/// <summary>
/// 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).
/// </summary>
public static (IReadOnlyList<string>? Send, IReadOnlyList<string>? 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<string>? AsStringList(JsonNode? node) =>
node is JsonArray arr ? arr.Select(n => n?.GetValue<string>() ?? string.Empty).ToArray() : null;
/// <summary>
/// One-call shape for the control surface's <c>GET /topology</c>: returns
/// the current sender + receiver group lists alongside a computed
/// <c>mode</c> 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).
/// </summary>
public static (string Mode, IReadOnlyList<string> Senders, IReadOnlyList<string> Receivers) ReadCurrent()
{
var (send, recv) = ReadCurrentGroups();
var senders = send ?? Array.Empty<string>();
var receivers = recv ?? Array.Empty<string>();
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}