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 const int MaximumPlayoutCodeLength = 64; private const int MaximumPlayoutTitleLength = 200; private const int MaximumPlayoutDetailLength = 500; 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 = TryParsePlayoutCommand(payload, out var request, out var validationError); if (!parsed) { PostPlayoutCommandError( request.RequestId, request.Command, "INVALID_REQUEST", validationError, 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 == PlayoutResultCode.OutcomeUnknown); 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: true, outcomeUnknown: true); } finally { Interlocked.Exchange(ref _playoutCommandInFlight, 0); _playoutCommandGate.Release(); } } private static Task ExecutePlayoutCommandAsync( IPlayoutEngine engine, ValidatedPlayoutCommand 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", processDetected = false, connected = false, liveTakeInAllowed = false, message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.", preparedCode = (string?)null, onAirCode = (string?)null, changedAt }); } else { PostMessage("playout-status", new { requestId, mode = "disabled", state = "IDLE", processDetected = false, connected = false, liveTakeInAllowed = false, 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), processDetected = status.IsProcessRunning, connected = status.IsConnected, liveTakeInAllowed = status.LiveTakeInAllowed, 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), processDetected = status.IsProcessRunning, connected = status.IsConnected, liveTakeInAllowed = status.LiveTakeInAllowed, 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 TryParsePlayoutCommand( JsonElement payload, out ValidatedPlayoutCommand request, out string error) { var requestId = payload.ValueKind == JsonValueKind.Object ? GetString(payload, "requestId") ?? string.Empty : string.Empty; var command = payload.ValueKind == JsonValueKind.Object ? GetString(payload, "command") ?? string.Empty : string.Empty; request = new ValidatedPlayoutCommand(requestId, command, null); if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "requestId", "command", "cue")) { error = "송출 요청에 허용되지 않은 필드가 있습니다."; return false; } if (!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId)) { error = "송출 요청 식별자가 올바르지 않습니다."; return false; } if (command is not ("prepare" or "take-in" or "next" or "take-out")) { error = "지원하지 않는 송출 명령입니다."; request = new ValidatedPlayoutCommand(requestId, command, null); return false; } request = new ValidatedPlayoutCommand(requestId, command, null); var cueRequired = command is not "take-out"; if (!payload.TryGetProperty("cue", out var cueElement)) { if (!cueRequired) { error = string.Empty; return true; } error = "이 송출 명령에는 그래픽 cue가 필요합니다."; return false; } if (!TryParsePlayoutCue(cueElement, out var cue, out error)) { return false; } request = new ValidatedPlayoutCommand(requestId, command, cue); return true; } private static bool TryParsePlayoutCue( JsonElement payload, out PlayoutCue? cue, out string error) { cue = null; if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "code", "title", "detail") || !TryGetSafeToken(payload, "code", MaximumPlayoutCodeLength, out var code) || !TryGetBoundedText(payload, "title", MaximumPlayoutTitleLength, allowEmpty: false, out _) || !TryGetBoundedText(payload, "detail", MaximumPlayoutDetailLength, allowEmpty: true, out _)) { error = "그래픽 cue 형식이 올바르지 않습니다."; return false; } var sceneFile = $"{code}.t2s"; if (!string.Equals(Path.GetFileName(sceneFile), sceneFile, StringComparison.Ordinal) || sceneFile.Contains("..", StringComparison.Ordinal)) { error = "그래픽 cue 경로가 올바르지 않습니다."; return false; } cue = new PlayoutCue(sceneFile, code); error = string.Empty; return true; } 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 TryGetBoundedText( JsonElement payload, string propertyName, int maximumLength, bool allowEmpty, out string value) { value = GetString(payload, propertyName) ?? string.Empty; return value.Length <= maximumLength && (allowEmpty || value.Length > 0) && !value.Any(char.IsControl); } private static bool HasOnlyProperties(JsonElement payload, params string[] allowed) { var seen = new HashSet(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(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.TimedOut or PlayoutResultCode.Unavailable or PlayoutResultCode.Failed or PlayoutResultCode.OutcomeUnknown; 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. } } private sealed record ValidatedPlayoutCommand( string RequestId, string Command, PlayoutCue? Cue); }