feat: migrate legacy playout workflow and scenes
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -8,19 +10,36 @@ 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
|
||||
{
|
||||
_playoutEngine = PlayoutEngineFactory.CreateDefault();
|
||||
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)
|
||||
{
|
||||
@@ -57,6 +76,13 @@ public sealed partial class MainWindow
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -92,7 +118,7 @@ public sealed partial class MainWindow
|
||||
|
||||
private async Task HandlePlayoutCommandAsync(JsonElement payload)
|
||||
{
|
||||
var parsed = PlayoutBridgeProtocol.ParseCommand(payload);
|
||||
var parsed = PlayoutBridgeProtocol.ParseWorkflowCommand(payload);
|
||||
var request = parsed.Request;
|
||||
if (!parsed.IsValid)
|
||||
{
|
||||
@@ -106,8 +132,21 @@ public sealed partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsBrowserCorrelationQuarantined())
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"OUTCOME_UNKNOWN",
|
||||
BrowserCorrelationQuarantineMessage,
|
||||
retryable: false,
|
||||
outcomeUnknown: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null)
|
||||
var workflow = _playoutWorkflow;
|
||||
if (engine is null || workflow is null)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
@@ -134,8 +173,42 @@ public sealed partial class MainWindow
|
||||
try
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||
var result = await ExecutePlayoutCommandAsync(engine, request, _lifetimeCancellation.Token);
|
||||
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)
|
||||
{
|
||||
@@ -149,6 +222,19 @@ public sealed partial class MainWindow
|
||||
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,
|
||||
@@ -158,7 +244,24 @@ public sealed partial class MainWindow
|
||||
state = ToPlayoutPhase(status),
|
||||
message = result.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName
|
||||
onAirCode = status.OnAirSceneName,
|
||||
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)
|
||||
@@ -171,6 +274,27 @@ public sealed partial class MainWindow
|
||||
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(
|
||||
@@ -192,23 +316,73 @@ public sealed partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
IPlayoutEngine engine,
|
||||
PlayoutBridgeCommand request,
|
||||
LegacyPlayoutWorkflow workflow,
|
||||
PlayoutBridgeWorkflowCommand request,
|
||||
CancellationToken cancellationToken) => request.Command switch
|
||||
{
|
||||
"prepare" => engine.PrepareAsync(request.Cue!, cancellationToken),
|
||||
"take-in" => engine.TakeInAsync(cancellationToken),
|
||||
"next" => engine.NextAsync(request.Cue!, cancellationToken),
|
||||
"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" => engine.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken),
|
||||
"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;
|
||||
@@ -218,7 +392,9 @@ public sealed partial class MainWindow
|
||||
{
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
connectionState = "unavailable",
|
||||
connectionState = browserCorrelationQuarantined
|
||||
? "outcome-unknown"
|
||||
: "unavailable",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
lastKtapConnectState = "not-attempted",
|
||||
@@ -229,11 +405,29 @@ public sealed partial class MainWindow
|
||||
networkMonitoringCheckRequired = false,
|
||||
commandAvailable = false,
|
||||
liveTakeInAllowed = false,
|
||||
outcomeUnknown = 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
|
||||
});
|
||||
}
|
||||
@@ -244,7 +438,9 @@ public sealed partial class MainWindow
|
||||
requestId,
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
connectionState = "unavailable",
|
||||
connectionState = browserCorrelationQuarantined
|
||||
? "outcome-unknown"
|
||||
: "unavailable",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
lastKtapConnectState = "not-attempted",
|
||||
@@ -255,11 +451,29 @@ public sealed partial class MainWindow
|
||||
networkMonitoringCheckRequired = false,
|
||||
commandAvailable = false,
|
||||
liveTakeInAllowed = false,
|
||||
outcomeUnknown = 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
|
||||
});
|
||||
}
|
||||
@@ -272,12 +486,16 @@ public sealed partial class MainWindow
|
||||
}
|
||||
else
|
||||
{
|
||||
var workflowState = _playoutWorkflow?.State;
|
||||
var refreshStatus = GetLegacyRefreshStatus();
|
||||
PostMessage("playout-status", new
|
||||
{
|
||||
requestId,
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
connectionState = ToWireValue(status.State),
|
||||
connectionState = browserCorrelationQuarantined
|
||||
? "outcome-unknown"
|
||||
: ToWireValue(status.State),
|
||||
processDetected = status.IsProcessRunning,
|
||||
connected = status.IsConnected,
|
||||
lastKtapConnectState = ToWireValue(status.LastKtapConnectState),
|
||||
@@ -286,39 +504,100 @@ public sealed partial class MainWindow
|
||||
ktapHelloObserved = status.KtapHelloObserved,
|
||||
networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected,
|
||||
networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
|
||||
commandAvailable = status.IsCommandAvailable,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown,
|
||||
commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
|
||||
outcomeUnknown = browserCorrelationQuarantined ||
|
||||
status.State == PlayoutConnectionState.OutcomeUnknown,
|
||||
browserCorrelationQuarantined,
|
||||
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
||||
message = status.Message,
|
||||
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 object CreatePlayoutStatusPayload(PlayoutStatus status) => new
|
||||
private object CreatePlayoutStatusPayload(PlayoutStatus status)
|
||||
{
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
connectionState = 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,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown,
|
||||
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
||||
message = status.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName,
|
||||
changedAt = status.ChangedAtUtc
|
||||
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,
|
||||
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(
|
||||
@@ -424,6 +703,169 @@ public sealed partial class MainWindow
|
||||
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);
|
||||
await _playoutCommandGate.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||
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);
|
||||
_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 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)
|
||||
@@ -431,7 +873,9 @@ public sealed partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
StopLegacyRefreshLoop();
|
||||
var engine = Interlocked.Exchange(ref _playoutEngine, null);
|
||||
_playoutWorkflow = null;
|
||||
if (engine is null)
|
||||
{
|
||||
return;
|
||||
@@ -461,4 +905,21 @@ public sealed partial class MainWindow
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user