Some checks failed
CI / build-and-test (push) Failing after 30s
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.
210 lines
8.5 KiB
C#
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;
|
|
}
|
|
}
|