432 lines
14 KiB
C#
432 lines
14 KiB
C#
using System.Text.Json;
|
|
using MBN_STOCK_WEBVIEW.Core.Playout;
|
|
using MBN_STOCK_WEBVIEW.Playout;
|
|
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
|
|
|
namespace MBN_STOCK_WEBVIEW;
|
|
|
|
public sealed partial class MainWindow
|
|
{
|
|
private const int MaximumPlayoutRequestIdLength = 128;
|
|
|
|
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
|
|
private IPlayoutEngine? _playoutEngine;
|
|
private string? _playoutInitializationError;
|
|
private int _playoutCommandInFlight;
|
|
private int _playoutShutdownStarted;
|
|
|
|
private void InitializePlayoutRuntime()
|
|
{
|
|
try
|
|
{
|
|
_playoutEngine = PlayoutEngineFactory.CreateDefault();
|
|
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_playoutInitializationError = exception is PlayoutConfigurationException
|
|
? exception.Message
|
|
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
|
|
}
|
|
}
|
|
|
|
private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
|
|
{
|
|
var engine = _playoutEngine;
|
|
if (engine is null || cancellationToken.IsCancellationRequested)
|
|
{
|
|
QueuePlayoutStatus();
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await engine.ConnectAsync(cancellationToken);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
_playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
|
|
}
|
|
|
|
QueuePlayoutStatus();
|
|
}
|
|
|
|
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
|
|
{
|
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0 ||
|
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
QueuePlayoutStatus();
|
|
}
|
|
|
|
private void QueuePlayoutStatus()
|
|
{
|
|
if (DispatcherQueue.HasThreadAccess)
|
|
{
|
|
PostPlayoutStatus();
|
|
return;
|
|
}
|
|
|
|
DispatcherQueue.TryEnqueue(() => PostPlayoutStatus());
|
|
}
|
|
|
|
private void HandlePlayoutStatusRequest(JsonElement payload)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object ||
|
|
!HasOnlyProperties(payload, "requestId") ||
|
|
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
|
|
{
|
|
PostMessage("bridge-error", new { message = "올바르지 않은 송출 상태 요청입니다." });
|
|
return;
|
|
}
|
|
|
|
PostPlayoutStatus(requestId);
|
|
}
|
|
|
|
private async Task HandlePlayoutCommandAsync(JsonElement payload)
|
|
{
|
|
var parsed = PlayoutBridgeProtocol.ParseCommand(payload);
|
|
var request = parsed.Request;
|
|
if (!parsed.IsValid)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"INVALID_REQUEST",
|
|
parsed.Error,
|
|
retryable: false,
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
var engine = _playoutEngine;
|
|
if (engine is null)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"PLAYOUT_UNAVAILABLE",
|
|
_playoutInitializationError ?? "Tornado 송출 어댑터를 사용할 수 없습니다.",
|
|
retryable: false,
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
if (!await _playoutCommandGate.WaitAsync(0))
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"PLAYOUT_BUSY",
|
|
"다른 송출 명령을 처리하고 있습니다.",
|
|
retryable: true,
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
|
var result = await ExecutePlayoutCommandAsync(engine, request, _lifetimeCancellation.Token);
|
|
var status = engine.Status;
|
|
|
|
if (!result.IsSuccess)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
ToWireValue(result.Code),
|
|
result.Message,
|
|
IsRetryable(result.Code),
|
|
result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut);
|
|
return;
|
|
}
|
|
|
|
PostMessage("playout-command-result", new
|
|
{
|
|
requestId = request.RequestId,
|
|
command = request.Command,
|
|
succeeded = true,
|
|
dryRun = result.IsDryRun,
|
|
state = ToPlayoutPhase(status),
|
|
message = result.Message,
|
|
preparedCode = status.PreparedSceneName,
|
|
onAirCode = status.OnAirSceneName
|
|
});
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"CANCELLED",
|
|
"송출 요청이 취소되었습니다.",
|
|
retryable: true,
|
|
outcomeUnknown: false);
|
|
}
|
|
catch
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"PLAYOUT_FAILED",
|
|
"송출 엔진 명령을 완료하지 못했습니다.",
|
|
retryable: false,
|
|
outcomeUnknown: true);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
|
_playoutCommandGate.Release();
|
|
// A browser-side timeout may have discarded the correlated result while the
|
|
// native operation was still completing. Always publish the authoritative
|
|
// engine state after the command leaves the serialization gate.
|
|
QueuePlayoutStatus();
|
|
}
|
|
}
|
|
|
|
private static Task<PlayoutResult> ExecutePlayoutCommandAsync(
|
|
IPlayoutEngine engine,
|
|
PlayoutBridgeCommand request,
|
|
CancellationToken cancellationToken) => request.Command switch
|
|
{
|
|
"prepare" => engine.PrepareAsync(request.Cue!, cancellationToken),
|
|
"take-in" => engine.TakeInAsync(cancellationToken),
|
|
"next" => engine.NextAsync(request.Cue!, cancellationToken),
|
|
// Preserve the legacy MainForm TAKE OUT behavior (StopAll on the selected
|
|
// scene player). Test/Live safety gates are enforced inside the engine.
|
|
"take-out" => engine.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken),
|
|
_ => throw new InvalidOperationException("검증되지 않은 송출 명령입니다.")
|
|
};
|
|
|
|
private void PostPlayoutStatus(string? requestId = null)
|
|
{
|
|
var status = _playoutEngine?.Status;
|
|
if (status is null)
|
|
{
|
|
var changedAt = DateTimeOffset.UtcNow;
|
|
if (requestId is null)
|
|
{
|
|
PostMessage("playout-status", new
|
|
{
|
|
mode = "disabled",
|
|
state = "IDLE",
|
|
connectionState = "unavailable",
|
|
processDetected = false,
|
|
connected = false,
|
|
commandAvailable = false,
|
|
liveTakeInAllowed = false,
|
|
outcomeUnknown = false,
|
|
operationTimeoutMilliseconds = 5000,
|
|
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
|
preparedCode = (string?)null,
|
|
onAirCode = (string?)null,
|
|
changedAt
|
|
});
|
|
}
|
|
else
|
|
{
|
|
PostMessage("playout-status", new
|
|
{
|
|
requestId,
|
|
mode = "disabled",
|
|
state = "IDLE",
|
|
connectionState = "unavailable",
|
|
processDetected = false,
|
|
connected = false,
|
|
commandAvailable = false,
|
|
liveTakeInAllowed = false,
|
|
outcomeUnknown = false,
|
|
operationTimeoutMilliseconds = 5000,
|
|
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
|
preparedCode = (string?)null,
|
|
onAirCode = (string?)null,
|
|
changedAt
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (requestId is null)
|
|
{
|
|
PostMessage("playout-status", CreatePlayoutStatusPayload(status));
|
|
}
|
|
else
|
|
{
|
|
PostMessage("playout-status", new
|
|
{
|
|
requestId,
|
|
mode = ToWireValue(status.Mode),
|
|
state = ToPlayoutPhase(status),
|
|
connectionState = ToWireValue(status.State),
|
|
processDetected = status.IsProcessRunning,
|
|
connected = status.IsConnected,
|
|
commandAvailable = status.IsCommandAvailable,
|
|
liveTakeInAllowed = status.LiveTakeInAllowed,
|
|
outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown,
|
|
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
|
message = status.Message,
|
|
preparedCode = status.PreparedSceneName,
|
|
onAirCode = status.OnAirSceneName,
|
|
changedAt = status.ChangedAtUtc
|
|
});
|
|
}
|
|
}
|
|
|
|
private static object CreatePlayoutStatusPayload(PlayoutStatus status) => new
|
|
{
|
|
mode = ToWireValue(status.Mode),
|
|
state = ToPlayoutPhase(status),
|
|
connectionState = ToWireValue(status.State),
|
|
processDetected = status.IsProcessRunning,
|
|
connected = status.IsConnected,
|
|
commandAvailable = status.IsCommandAvailable,
|
|
liveTakeInAllowed = status.LiveTakeInAllowed,
|
|
outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown,
|
|
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
|
message = status.Message,
|
|
preparedCode = status.PreparedSceneName,
|
|
onAirCode = status.OnAirSceneName,
|
|
changedAt = status.ChangedAtUtc
|
|
};
|
|
|
|
private void PostPlayoutCommandError(
|
|
string requestId,
|
|
string command,
|
|
string code,
|
|
string message,
|
|
bool retryable,
|
|
bool outcomeUnknown)
|
|
{
|
|
PostMessage("playout-command-error", new
|
|
{
|
|
requestId,
|
|
command,
|
|
code,
|
|
message,
|
|
retryable,
|
|
outcomeUnknown
|
|
});
|
|
}
|
|
|
|
private static bool TryGetSafeToken(
|
|
JsonElement payload,
|
|
string propertyName,
|
|
int maximumLength,
|
|
out string value)
|
|
{
|
|
value = GetString(payload, propertyName) ?? string.Empty;
|
|
return value.Length is > 0 && value.Length <= maximumLength &&
|
|
value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');
|
|
}
|
|
|
|
private static bool HasOnlyProperties(JsonElement payload, params string[] allowed)
|
|
{
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var property in payload.EnumerateObject())
|
|
{
|
|
if (!seen.Add(property.Name) ||
|
|
!allowed.Contains(property.Name, StringComparer.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string ToPlayoutPhase(PlayoutStatus status)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(status.OnAirSceneName))
|
|
{
|
|
return "PROGRAM";
|
|
}
|
|
|
|
return !string.IsNullOrWhiteSpace(status.PreparedSceneName) ? "PREPARED" : "IDLE";
|
|
}
|
|
|
|
private static string ToWireValue(PlayoutMode mode) => mode switch
|
|
{
|
|
PlayoutMode.Disabled => "disabled",
|
|
PlayoutMode.DryRun => "dry-run",
|
|
PlayoutMode.Test => "test",
|
|
PlayoutMode.Live => "live",
|
|
_ => "disabled"
|
|
};
|
|
|
|
private static string ToWireValue(PlayoutConnectionState state) => state switch
|
|
{
|
|
PlayoutConnectionState.Disabled => "disabled",
|
|
PlayoutConnectionState.DryRunReady => "dry-run-ready",
|
|
PlayoutConnectionState.Disconnected => "disconnected",
|
|
PlayoutConnectionState.Connecting => "connecting",
|
|
PlayoutConnectionState.Connected => "connected",
|
|
PlayoutConnectionState.Reconnecting => "reconnecting",
|
|
PlayoutConnectionState.Disconnecting => "disconnecting",
|
|
PlayoutConnectionState.Faulted => "faulted",
|
|
PlayoutConnectionState.OutcomeUnknown => "outcome-unknown",
|
|
PlayoutConnectionState.Disposed => "disposed",
|
|
_ => "unavailable"
|
|
};
|
|
|
|
private static string ToWireValue(PlayoutResultCode code) => code switch
|
|
{
|
|
PlayoutResultCode.Rejected => "REJECTED",
|
|
PlayoutResultCode.Cancelled => "CANCELLED",
|
|
PlayoutResultCode.TimedOut => "TIMED_OUT",
|
|
PlayoutResultCode.Unavailable => "UNAVAILABLE",
|
|
PlayoutResultCode.OutcomeUnknown => "OUTCOME_UNKNOWN",
|
|
_ => "PLAYOUT_FAILED"
|
|
};
|
|
|
|
private static bool IsRetryable(PlayoutResultCode code) => code is
|
|
PlayoutResultCode.Cancelled or
|
|
PlayoutResultCode.Unavailable or
|
|
PlayoutResultCode.Failed;
|
|
|
|
private void ShutdownPlayoutRuntime()
|
|
{
|
|
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var engine = Interlocked.Exchange(ref _playoutEngine, null);
|
|
if (engine is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
engine.StatusChanged -= OnPlayoutStatusChanged;
|
|
using var disconnectCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
|
try
|
|
{
|
|
var disconnect = engine.DisconnectAsync(disconnectCancellation.Token);
|
|
if (!disconnect.Wait(TimeSpan.FromSeconds(4)))
|
|
{
|
|
disconnectCancellation.Cancel();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// App shutdown continues even when the external playout process is unavailable.
|
|
}
|
|
|
|
try
|
|
{
|
|
engine.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2));
|
|
}
|
|
catch
|
|
{
|
|
// Disposal is best-effort and bounded during window shutdown.
|
|
}
|
|
}
|
|
}
|