1029 lines
39 KiB
C#
1029 lines
39 KiB
C#
using System.Text.Json;
|
|
using MBN_STOCK_WEBVIEW.Core.Playout;
|
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
|
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 string BrowserCorrelationQuarantineMessage =
|
|
"Browser correlation was lost before the playout result could be confirmed. Verify PGM output and restart the app before issuing another command.";
|
|
|
|
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
|
|
private IPlayoutEngine? _playoutEngine;
|
|
private LegacyPlayoutWorkflow? _playoutWorkflow;
|
|
private string? _playoutInitializationError;
|
|
private int _playoutCommandInFlight;
|
|
private int _playoutShutdownStarted;
|
|
private int _browserCorrelationQuarantined;
|
|
private CancellationTokenSource? _legacyRefreshCancellation;
|
|
private Task? _legacyRefreshTask;
|
|
private readonly object _legacyRefreshStatusGate = new();
|
|
private LegacyRefreshRuntimeStatus _legacyRefreshStatus = LegacyRefreshRuntimeStatus.Empty;
|
|
|
|
private void InitializePlayoutRuntime()
|
|
{
|
|
try
|
|
{
|
|
var options = PlayoutOptionsLoader.Load();
|
|
var compositionOptions = PlayoutSceneCompositionFactory.Create(options);
|
|
_playoutEngine = PlayoutEngineFactory.Create(options);
|
|
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
|
|
if (_databaseRuntime is not null)
|
|
{
|
|
_playoutWorkflow = LegacySceneRuntimeFactory.Create(
|
|
_playoutEngine,
|
|
_databaseRuntime.Executor,
|
|
compositionOptions);
|
|
}
|
|
}
|
|
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)
|
|
{
|
|
_playoutWorkflow?.ObserveEngineStatus(args.Current);
|
|
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
|
|
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
|
|
{
|
|
StopLegacyRefreshLoop();
|
|
ResetLegacyRefreshStatus();
|
|
}
|
|
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.ParseWorkflowCommand(payload);
|
|
var request = parsed.Request;
|
|
if (!parsed.IsValid)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"INVALID_REQUEST",
|
|
parsed.Error,
|
|
retryable: false,
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
if (IsBrowserCorrelationQuarantined())
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"OUTCOME_UNKNOWN",
|
|
BrowserCorrelationQuarantineMessage,
|
|
retryable: false,
|
|
outcomeUnknown: true);
|
|
return;
|
|
}
|
|
|
|
var engine = _playoutEngine;
|
|
var workflow = _playoutWorkflow;
|
|
if (engine is null || workflow 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;
|
|
}
|
|
|
|
var databaseActivityEntered = false;
|
|
try
|
|
{
|
|
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
|
CancelActiveMarketDataRequest();
|
|
CancelActiveDatabaseHealthCheck();
|
|
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
|
|
databaseActivityEntered = true;
|
|
if (IsBrowserCorrelationQuarantined())
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"OUTCOME_UNKNOWN",
|
|
BrowserCorrelationQuarantineMessage,
|
|
retryable: false,
|
|
outcomeUnknown: true);
|
|
return;
|
|
}
|
|
|
|
var refreshBeforeCommand = GetLegacyRefreshStatus();
|
|
if (refreshBeforeCommand.IsFaulted && request.Command != "take-out")
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"REFRESH_FAULT",
|
|
refreshBeforeCommand.FaultMessage ??
|
|
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 상태를 확인하세요.",
|
|
retryable: false,
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
var nextKindBeforeCommand = workflow.State.NextKind;
|
|
// MainForm stops timer1 before every operator command. TAKE IN and a
|
|
// successful playlist NEXT start it again; Page NEXT intentionally does not.
|
|
StopLegacyRefreshLoop();
|
|
var result = await ExecutePlayoutCommandAsync(
|
|
workflow,
|
|
request,
|
|
_lifetimeCancellation.Token);
|
|
var status = engine.Status;
|
|
var workflowState = workflow.State;
|
|
|
|
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;
|
|
}
|
|
|
|
if (request.Command == "take-in" ||
|
|
(request.Command == "next" &&
|
|
nextKindBeforeCommand == LegacyWorkflowNextKind.PlaylistNext))
|
|
{
|
|
StartLegacyRefreshLoop(workflow);
|
|
}
|
|
else if (request.Command == "take-out")
|
|
{
|
|
ResetLegacyRefreshStatus();
|
|
}
|
|
|
|
var refreshStatus = GetLegacyRefreshStatus();
|
|
|
|
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,
|
|
playCompletionPending = status.IsPlayCompletionPending,
|
|
currentCueIndex = workflowState.CurrentCueIndexZeroBased,
|
|
currentEntryId = workflowState.CurrentEntryId,
|
|
builderKey = workflowState.BuilderKey,
|
|
pageIndex = workflowState.PageIndexZeroBased,
|
|
pageCount = workflowState.PageCount,
|
|
pageSize = workflowState.PageSize,
|
|
itemCount = workflowState.ItemCount,
|
|
currentPageItemCount = workflowState.CurrentPageItemCount,
|
|
isLastPage = workflowState.IsLastPage,
|
|
nextKind = ToWireValue(workflowState.NextKind),
|
|
previewFields = workflowState.PreviewFields,
|
|
refreshActive = refreshStatus.IsActive,
|
|
refreshNextAt = refreshStatus.NextAtUtc,
|
|
refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
|
|
refreshFaulted = refreshStatus.IsFaulted,
|
|
refreshFaultCode = refreshStatus.FaultCode,
|
|
refreshFaultMessage = refreshStatus.FaultMessage
|
|
});
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"CANCELLED",
|
|
"송출 요청이 취소되었습니다.",
|
|
retryable: true,
|
|
outcomeUnknown: false);
|
|
}
|
|
catch (DatabaseOperationException exception)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"DATABASE_UNAVAILABLE",
|
|
exception.Message,
|
|
retryable: true,
|
|
outcomeUnknown: false);
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is LegacySceneDataException or ArgumentException)
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"SCENE_DATA_INVALID",
|
|
exception.Message,
|
|
retryable: false,
|
|
outcomeUnknown: false);
|
|
}
|
|
catch
|
|
{
|
|
PostPlayoutCommandError(
|
|
request.RequestId,
|
|
request.Command,
|
|
"PLAYOUT_FAILED",
|
|
"송출 엔진 명령을 완료하지 못했습니다.",
|
|
retryable: false,
|
|
outcomeUnknown: true);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
|
if (databaseActivityEntered)
|
|
{
|
|
_databaseActivityGate.Release();
|
|
}
|
|
_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 async Task HandlePlayoutTimeoutQuarantineAsync(JsonElement payload)
|
|
{
|
|
var parsed = PlayoutBridgeProtocol.ParseTimeoutQuarantine(payload);
|
|
if (!parsed.IsValid)
|
|
{
|
|
PostMessage("bridge-error", new { message = parsed.Error });
|
|
return;
|
|
}
|
|
|
|
await QuarantineForBrowserCorrelationLossAsync();
|
|
}
|
|
|
|
private async Task QuarantineForBrowserCorrelationLossAsync()
|
|
{
|
|
|
|
if (Interlocked.Exchange(ref _browserCorrelationQuarantined, 1) != 0)
|
|
{
|
|
QueuePlayoutStatus();
|
|
return;
|
|
}
|
|
|
|
// Latch before the first await so a reload or a second WebView message can
|
|
// never race ahead of native quarantine. The latch is process-lifetime only.
|
|
StopLegacyRefreshLoop();
|
|
QueuePlayoutStatus();
|
|
|
|
var engine = _playoutEngine;
|
|
if (engine is not null)
|
|
{
|
|
try
|
|
{
|
|
await engine.QuarantineAsync();
|
|
}
|
|
catch
|
|
{
|
|
// The native latch remains authoritative even if abandoning the vendor
|
|
// session cannot be confirmed. Retrying any command would be unsafe.
|
|
}
|
|
}
|
|
|
|
QueuePlayoutStatus();
|
|
}
|
|
|
|
private bool IsBrowserCorrelationQuarantined() =>
|
|
Volatile.Read(ref _browserCorrelationQuarantined) != 0;
|
|
|
|
private static Task<PlayoutResult> ExecutePlayoutCommandAsync(
|
|
LegacyPlayoutWorkflow workflow,
|
|
PlayoutBridgeWorkflowCommand request,
|
|
CancellationToken cancellationToken) => request.Command switch
|
|
{
|
|
"prepare" => workflow.PrepareAsync(
|
|
request.Playlist!,
|
|
request.SelectedIndexZeroBased,
|
|
cancellationToken),
|
|
"take-in" => workflow.TakeInAsync(cancellationToken),
|
|
"next" => workflow.NextAsync(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" => workflow.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken),
|
|
_ => throw new InvalidOperationException("검증되지 않은 송출 명령입니다.")
|
|
};
|
|
|
|
private void PostPlayoutStatus(string? requestId = null)
|
|
{
|
|
var status = _playoutEngine?.Status;
|
|
var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined();
|
|
if (status is null)
|
|
{
|
|
var changedAt = DateTimeOffset.UtcNow;
|
|
if (requestId is null)
|
|
{
|
|
PostMessage("playout-status", new
|
|
{
|
|
mode = "disabled",
|
|
state = "IDLE",
|
|
connectionState = browserCorrelationQuarantined
|
|
? "outcome-unknown"
|
|
: "unavailable",
|
|
processDetected = false,
|
|
connected = false,
|
|
lastKtapConnectState = "not-attempted",
|
|
ktapConnectAttempted = false,
|
|
ktapConnectAccepted = false,
|
|
ktapHelloObserved = (bool?)null,
|
|
networkMonitoringRecordExpected = false,
|
|
networkMonitoringCheckRequired = false,
|
|
commandAvailable = false,
|
|
liveTakeInAllowed = false,
|
|
playCompletionPending = false,
|
|
outcomeUnknown = browserCorrelationQuarantined,
|
|
browserCorrelationQuarantined,
|
|
operationTimeoutMilliseconds = 5000,
|
|
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
|
preparedCode = (string?)null,
|
|
onAirCode = (string?)null,
|
|
currentCueIndex = -1,
|
|
currentEntryId = (string?)null,
|
|
builderKey = (string?)null,
|
|
pageIndex = 0,
|
|
pageCount = 0,
|
|
pageSize = 0,
|
|
itemCount = 0,
|
|
currentPageItemCount = 0,
|
|
isLastPage = true,
|
|
nextKind = "none",
|
|
previewFields = Array.Empty<LegacyScenePreviewField>(),
|
|
refreshActive = false,
|
|
refreshNextAt = (DateTimeOffset?)null,
|
|
refreshLastSuccessAt = (DateTimeOffset?)null,
|
|
refreshFaulted = false,
|
|
refreshFaultCode = (string?)null,
|
|
refreshFaultMessage = (string?)null,
|
|
changedAt
|
|
});
|
|
}
|
|
else
|
|
{
|
|
PostMessage("playout-status", new
|
|
{
|
|
requestId,
|
|
mode = "disabled",
|
|
state = "IDLE",
|
|
connectionState = browserCorrelationQuarantined
|
|
? "outcome-unknown"
|
|
: "unavailable",
|
|
processDetected = false,
|
|
connected = false,
|
|
lastKtapConnectState = "not-attempted",
|
|
ktapConnectAttempted = false,
|
|
ktapConnectAccepted = false,
|
|
ktapHelloObserved = (bool?)null,
|
|
networkMonitoringRecordExpected = false,
|
|
networkMonitoringCheckRequired = false,
|
|
commandAvailable = false,
|
|
liveTakeInAllowed = false,
|
|
playCompletionPending = false,
|
|
outcomeUnknown = browserCorrelationQuarantined,
|
|
browserCorrelationQuarantined,
|
|
operationTimeoutMilliseconds = 5000,
|
|
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
|
preparedCode = (string?)null,
|
|
onAirCode = (string?)null,
|
|
currentCueIndex = -1,
|
|
currentEntryId = (string?)null,
|
|
builderKey = (string?)null,
|
|
pageIndex = 0,
|
|
pageCount = 0,
|
|
pageSize = 0,
|
|
itemCount = 0,
|
|
currentPageItemCount = 0,
|
|
isLastPage = true,
|
|
nextKind = "none",
|
|
previewFields = Array.Empty<LegacyScenePreviewField>(),
|
|
refreshActive = false,
|
|
refreshNextAt = (DateTimeOffset?)null,
|
|
refreshLastSuccessAt = (DateTimeOffset?)null,
|
|
refreshFaulted = false,
|
|
refreshFaultCode = (string?)null,
|
|
refreshFaultMessage = (string?)null,
|
|
changedAt
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (requestId is null)
|
|
{
|
|
PostMessage("playout-status", CreatePlayoutStatusPayload(status));
|
|
}
|
|
else
|
|
{
|
|
var workflowState = _playoutWorkflow?.State;
|
|
var refreshStatus = GetLegacyRefreshStatus();
|
|
PostMessage("playout-status", new
|
|
{
|
|
requestId,
|
|
mode = ToWireValue(status.Mode),
|
|
state = ToPlayoutPhase(status),
|
|
connectionState = browserCorrelationQuarantined
|
|
? "outcome-unknown"
|
|
: ToWireValue(status.State),
|
|
processDetected = status.IsProcessRunning,
|
|
connected = status.IsConnected,
|
|
lastKtapConnectState = ToWireValue(status.LastKtapConnectState),
|
|
ktapConnectAttempted = status.KtapConnectAttempted,
|
|
ktapConnectAccepted = status.KtapConnectAccepted,
|
|
ktapHelloObserved = status.KtapHelloObserved,
|
|
networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected,
|
|
networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
|
|
commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
|
|
liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
|
|
playCompletionPending = status.IsPlayCompletionPending,
|
|
outcomeUnknown = browserCorrelationQuarantined ||
|
|
status.State == PlayoutConnectionState.OutcomeUnknown,
|
|
browserCorrelationQuarantined,
|
|
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
|
message = browserCorrelationQuarantined
|
|
? BrowserCorrelationQuarantineMessage
|
|
: status.Message,
|
|
preparedCode = status.PreparedSceneName,
|
|
onAirCode = status.OnAirSceneName,
|
|
currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1,
|
|
currentEntryId = workflowState?.CurrentEntryId,
|
|
builderKey = workflowState?.BuilderKey,
|
|
pageIndex = workflowState?.PageIndexZeroBased ?? 0,
|
|
pageCount = workflowState?.PageCount ?? 0,
|
|
pageSize = workflowState?.PageSize ?? 0,
|
|
itemCount = workflowState?.ItemCount ?? 0,
|
|
currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0,
|
|
isLastPage = workflowState?.IsLastPage ?? true,
|
|
nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None),
|
|
previewFields = workflowState?.PreviewFields ??
|
|
Array.Empty<LegacyScenePreviewField>(),
|
|
refreshActive = refreshStatus.IsActive,
|
|
refreshNextAt = refreshStatus.NextAtUtc,
|
|
refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
|
|
refreshFaulted = refreshStatus.IsFaulted,
|
|
refreshFaultCode = refreshStatus.FaultCode,
|
|
refreshFaultMessage = refreshStatus.FaultMessage,
|
|
changedAt = status.ChangedAtUtc
|
|
});
|
|
}
|
|
}
|
|
|
|
private object CreatePlayoutStatusPayload(PlayoutStatus status)
|
|
{
|
|
var workflowState = _playoutWorkflow?.State;
|
|
var refreshStatus = GetLegacyRefreshStatus();
|
|
var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined();
|
|
return new
|
|
{
|
|
mode = ToWireValue(status.Mode),
|
|
state = ToPlayoutPhase(status),
|
|
connectionState = browserCorrelationQuarantined
|
|
? "outcome-unknown"
|
|
: ToWireValue(status.State),
|
|
processDetected = status.IsProcessRunning,
|
|
connected = status.IsConnected,
|
|
lastKtapConnectState = ToWireValue(status.LastKtapConnectState),
|
|
ktapConnectAttempted = status.KtapConnectAttempted,
|
|
ktapConnectAccepted = status.KtapConnectAccepted,
|
|
ktapHelloObserved = status.KtapHelloObserved,
|
|
networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected,
|
|
networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
|
|
commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
|
|
liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
|
|
playCompletionPending = status.IsPlayCompletionPending,
|
|
outcomeUnknown = browserCorrelationQuarantined ||
|
|
status.State == PlayoutConnectionState.OutcomeUnknown,
|
|
browserCorrelationQuarantined,
|
|
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
|
message = browserCorrelationQuarantined
|
|
? BrowserCorrelationQuarantineMessage
|
|
: status.Message,
|
|
preparedCode = status.PreparedSceneName,
|
|
onAirCode = status.OnAirSceneName,
|
|
currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1,
|
|
currentEntryId = workflowState?.CurrentEntryId,
|
|
builderKey = workflowState?.BuilderKey,
|
|
pageIndex = workflowState?.PageIndexZeroBased ?? 0,
|
|
pageCount = workflowState?.PageCount ?? 0,
|
|
pageSize = workflowState?.PageSize ?? 0,
|
|
itemCount = workflowState?.ItemCount ?? 0,
|
|
currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0,
|
|
isLastPage = workflowState?.IsLastPage ?? true,
|
|
nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None),
|
|
previewFields = workflowState?.PreviewFields ??
|
|
Array.Empty<LegacyScenePreviewField>(),
|
|
refreshActive = refreshStatus.IsActive,
|
|
refreshNextAt = refreshStatus.NextAtUtc,
|
|
refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
|
|
refreshFaulted = refreshStatus.IsFaulted,
|
|
refreshFaultCode = refreshStatus.FaultCode,
|
|
refreshFaultMessage = refreshStatus.FaultMessage,
|
|
changedAt = status.ChangedAtUtc
|
|
};
|
|
}
|
|
|
|
private static string ToWireValue(LegacyWorkflowNextKind value) => value switch
|
|
{
|
|
LegacyWorkflowNextKind.None => "none",
|
|
LegacyWorkflowNextKind.PageNext => "page-next",
|
|
LegacyWorkflowNextKind.PlaylistNext => "playlist-next",
|
|
LegacyWorkflowNextKind.EndOfPlaylist => "end-of-playlist",
|
|
_ => "none"
|
|
};
|
|
|
|
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(PlayoutKtapConnectState state) => state switch
|
|
{
|
|
PlayoutKtapConnectState.NotAttempted => "not-attempted",
|
|
PlayoutKtapConnectState.Attempted => "attempted",
|
|
PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed",
|
|
PlayoutKtapConnectState.Failed => "failed",
|
|
_ => "not-attempted"
|
|
};
|
|
|
|
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 StartLegacyRefreshLoop(LegacyPlayoutWorkflow workflow)
|
|
{
|
|
StopLegacyRefreshLoop();
|
|
var cutCode = workflow.State.OnAirCutCode;
|
|
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var interval) ||
|
|
interval <= TimeSpan.Zero ||
|
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
|
_lifetimeCancellation.Token);
|
|
_legacyRefreshCancellation = cancellation;
|
|
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
|
true,
|
|
DateTimeOffset.UtcNow.Add(interval),
|
|
GetLegacyRefreshStatus().LastSuccessAtUtc,
|
|
false,
|
|
null,
|
|
null));
|
|
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
|
|
workflow,
|
|
interval,
|
|
cancellation);
|
|
}
|
|
|
|
private void StopLegacyRefreshLoop()
|
|
{
|
|
var cancellation = Interlocked.Exchange(
|
|
ref _legacyRefreshCancellation,
|
|
null);
|
|
if (cancellation is not null)
|
|
{
|
|
cancellation.Cancel();
|
|
}
|
|
|
|
var status = GetLegacyRefreshStatus();
|
|
if (status.IsActive)
|
|
{
|
|
SetLegacyRefreshStatus(status with { IsActive = false, NextAtUtc = null });
|
|
}
|
|
}
|
|
|
|
private async Task RunLegacyRefreshLoopAsync(
|
|
LegacyPlayoutWorkflow workflow,
|
|
TimeSpan interval,
|
|
CancellationTokenSource cancellation)
|
|
{
|
|
var token = cancellation.Token;
|
|
var nextDelay = interval;
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
await Task.Delay(nextDelay, token);
|
|
|
|
var engine = _playoutEngine;
|
|
if (engine is null ||
|
|
!ReferenceEquals(_playoutWorkflow, workflow) ||
|
|
!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Play returns before Tornado necessarily delivers OnScenePlayed. Waiting
|
|
// outside the app command gate keeps emergency TAKE OUT available and does
|
|
// not dispatch, repeat, or probe any SDK mutation.
|
|
if (!await WaitForPlayCompletionAsync(engine, token))
|
|
{
|
|
SetLegacyRefreshFault(
|
|
"PLAY_CALLBACK_TIMEOUT",
|
|
"Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요.");
|
|
QueuePlayoutStatus();
|
|
return;
|
|
}
|
|
|
|
await _playoutCommandGate.WaitAsync(token);
|
|
var databaseActivityEntered = false;
|
|
try
|
|
{
|
|
if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
|
CancelActiveMarketDataRequest();
|
|
CancelActiveDatabaseHealthCheck();
|
|
await _databaseActivityGate.WaitAsync(token);
|
|
databaseActivityEntered = true;
|
|
var result = await workflow.RefreshOnAirAsync(token);
|
|
// A failed, timed-out, or unknown update never repeats. The operator
|
|
// must reconcile native/PGM state before starting another command.
|
|
if (!result.IsSuccess)
|
|
{
|
|
SetLegacyRefreshFault(
|
|
ToWireValue(result.Code),
|
|
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요.");
|
|
return;
|
|
}
|
|
|
|
// MainForm changes timer1.Interval to 3000 after the first
|
|
// successful same-scene refresh.
|
|
nextDelay = LegacySceneRefreshPolicy.RecurringInterval;
|
|
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
|
true,
|
|
DateTimeOffset.UtcNow.Add(nextDelay),
|
|
DateTimeOffset.UtcNow,
|
|
false,
|
|
null,
|
|
null));
|
|
}
|
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
catch (DatabaseOperationException)
|
|
{
|
|
SetLegacyRefreshFault(
|
|
"DATABASE_UNAVAILABLE",
|
|
"자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
|
|
return;
|
|
}
|
|
catch (LegacySceneDataException)
|
|
{
|
|
SetLegacyRefreshFault(
|
|
"SCENE_DATA_INVALID",
|
|
"자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
SetLegacyRefreshFault(
|
|
"REFRESH_FAILED",
|
|
"자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요.");
|
|
return;
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
|
if (databaseActivityEntered)
|
|
{
|
|
_databaseActivityGate.Release();
|
|
}
|
|
_playoutCommandGate.Release();
|
|
QueuePlayoutStatus();
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
|
{
|
|
// Operator commands and shutdown cancel the legacy timer deterministically.
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.CompareExchange(
|
|
ref _legacyRefreshCancellation,
|
|
null,
|
|
cancellation);
|
|
cancellation.Dispose();
|
|
}
|
|
}
|
|
|
|
private static async Task<bool> WaitForPlayCompletionAsync(
|
|
IPlayoutEngine engine,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!engine.Status.IsPlayCompletionPending)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var timeoutMilliseconds = Math.Clamp(
|
|
engine.Status.OperationTimeoutMilliseconds,
|
|
100,
|
|
300_000);
|
|
using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
|
cancellationToken);
|
|
timeoutCancellation.CancelAfter(timeoutMilliseconds);
|
|
|
|
while (engine.Status.IsPlayCompletionPending)
|
|
{
|
|
var statusChanged = new TaskCompletionSource(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
void OnStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
|
|
{
|
|
if (!args.Current.IsPlayCompletionPending)
|
|
{
|
|
statusChanged.TrySetResult();
|
|
}
|
|
}
|
|
|
|
engine.StatusChanged += OnStatusChanged;
|
|
try
|
|
{
|
|
// Close the subscribe-after-read race without causing an engine call.
|
|
if (!engine.Status.IsPlayCompletionPending)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
await statusChanged.Task.WaitAsync(timeoutCancellation.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
engine.StatusChanged -= OnStatusChanged;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus()
|
|
{
|
|
lock (_legacyRefreshStatusGate)
|
|
{
|
|
return _legacyRefreshStatus;
|
|
}
|
|
}
|
|
|
|
private void SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus status)
|
|
{
|
|
lock (_legacyRefreshStatusGate)
|
|
{
|
|
_legacyRefreshStatus = status;
|
|
}
|
|
}
|
|
|
|
private void SetLegacyRefreshFault(string code, string message) =>
|
|
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
|
false,
|
|
null,
|
|
GetLegacyRefreshStatus().LastSuccessAtUtc,
|
|
true,
|
|
code,
|
|
message));
|
|
|
|
private void ResetLegacyRefreshStatus() =>
|
|
SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus.Empty);
|
|
|
|
private void ShutdownPlayoutRuntime()
|
|
{
|
|
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
StopLegacyRefreshLoop();
|
|
var engine = Interlocked.Exchange(ref _playoutEngine, null);
|
|
_playoutWorkflow = 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 LegacyRefreshRuntimeStatus(
|
|
bool IsActive,
|
|
DateTimeOffset? NextAtUtc,
|
|
DateTimeOffset? LastSuccessAtUtc,
|
|
bool IsFaulted,
|
|
string? FaultCode,
|
|
string? FaultMessage)
|
|
{
|
|
public static LegacyRefreshRuntimeStatus Empty { get; } = new(
|
|
false,
|
|
null,
|
|
null,
|
|
false,
|
|
null,
|
|
null);
|
|
}
|
|
}
|