181 lines
7.3 KiB
C#
181 lines
7.3 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;
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|