feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -20,15 +20,13 @@ public sealed partial class MainWindow
private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
private string? _playoutInitializationError;
private string? _playoutInitializationWarning;
private CancellationTokenSource? _refreshCancellation;
private Task? _refreshTask;
private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _refreshEpoch =
new(LegacyRefreshRuntimeStatus.Empty);
private int _playoutBusy;
private int _playoutQuarantined;
private int _playoutShutdownStarted;
private int _operatorMutationQuarantined;
private int _refreshCompletedCount;
private bool _refreshLimitReached;
private string _refreshMessage = string.Empty;
private void InitializePlayoutRuntime()
{
@@ -37,6 +35,7 @@ public sealed partial class MainWindow
var explicitLocalConfigurationExists = File.Exists(
PlayoutOptionsLoader.DefaultPath);
_playoutOptions = PlayoutOptionsLoader.Load();
ResetRefreshState();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions,
explicitLocalConfigurationExists);
@@ -103,7 +102,15 @@ public sealed partial class MainWindow
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
{
StopRefreshLoop();
if (args.Current.State == PlayoutConnectionState.OutcomeUnknown ||
Volatile.Read(ref _playoutQuarantined) != 0)
{
StopRefreshLoop();
}
else
{
ResetRefreshState();
}
}
QueueOperatorState();
@@ -418,6 +425,7 @@ public sealed partial class MainWindow
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var refresh = _refreshEpoch.ReadState();
return _playoutOptions is not null && _compositionOptions is not null &&
status is not null && workflow is not null &&
Volatile.Read(ref _playoutBusy) == 0 &&
@@ -428,6 +436,7 @@ public sealed partial class MainWindow
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
!refresh.IsActive &&
(_refreshTask is null || _refreshTask.IsCompleted);
}
@@ -435,6 +444,7 @@ public sealed partial class MainWindow
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var refresh = _refreshEpoch.ReadState();
if (status is null || workflow is null)
{
// A disabled or unavailable playout runtime must not prevent the operator
@@ -452,6 +462,7 @@ public sealed partial class MainWindow
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
!refresh.IsActive &&
(_refreshTask is null || _refreshTask.IsCompleted);
}
@@ -495,9 +506,11 @@ public sealed partial class MainWindow
LegacyMoveOperatorCatalogDraftRowIntent or
LegacyReorderOperatorCatalogDraftRowIntent or
LegacyRemoveOperatorCatalogDraftRowIntent or
LegacyRemoveActiveOperatorCatalogDraftRowIntent or
LegacyClearOperatorCatalogDraftRowsIntent or
LegacySetOperatorCatalogDraftBuyAmountIntent or
LegacySaveOperatorCatalogIntent or
LegacyDeleteOperatorCatalogIntent or
LegacyConfirmNativeDialogIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent or
LegacyCreateNamedPlaylistIntent or
@@ -548,6 +561,7 @@ public sealed partial class MainWindow
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var refresh = _refreshEpoch.ReadState();
var composition = _compositionOptions?.Current ??
LegacySceneCueCompositionOptions.Default;
var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 ||
@@ -584,10 +598,10 @@ public sealed partial class MainWindow
workflow?.IsLastPage ?? true,
workflow?.NextKind ?? LegacyWorkflowNextKind.None,
Volatile.Read(ref _playoutBusy) != 0,
_refreshTask is { IsCompleted: false },
Volatile.Read(ref _refreshCompletedCount),
_playoutOptions?.MaximumAutomaticRefreshesPerTakeIn,
_refreshLimitReached,
refresh.IsActive,
refresh.CompletedRefreshCount,
refresh.MaximumRefreshCount,
refresh.IsLimitReached,
composition.FadeDuration,
backgroundEnabled,
backgroundEnabled
@@ -596,125 +610,310 @@ public sealed partial class MainWindow
CanChangeComposition(),
quarantined
? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
: _refreshMessage.Length > 0
? _refreshMessage
: refresh.Message.Length > 0
? refresh.Message
: _playoutInitializationWarning ?? status?.Message ??
_playoutInitializationError ??
"송출 어댑터를 사용할 수 없습니다.");
return snapshot with
{
IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false
IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false,
RefreshNextAtUtc = refresh.NextAtUtc,
RefreshLastSuccessAtUtc = refresh.LastSuccessAtUtc,
RefreshFaultCode = refresh.FaultCode
};
}
private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
{
StopRefreshLoop();
Interlocked.Exchange(ref _refreshCompletedCount, 0);
_refreshLimitReached = false;
_refreshMessage = string.Empty;
ResetRefreshState();
var cutCode = workflow.State.OnAirCutCode;
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) ||
firstInterval <= TimeSpan.Zero)
firstInterval <= TimeSpan.Zero ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
var scheduler = new LegacyRefreshScheduler(
firstInterval,
LegacySceneRefreshPolicy.RecurringInterval,
_playoutOptions?.MaximumAutomaticRefreshesPerTakeIn);
if (scheduler.HasReachedMaximum)
{
_refreshTask = Task.CompletedTask;
_refreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with
{
MaximumRefreshCount = scheduler.MaximumRefreshCount,
IsLimitReached = true,
Message = $"자동 갱신 상한 {scheduler.MaximumRefreshCount}회가 설정되어 자동 갱신을 시작하지 않았습니다."
});
QueueOperatorState();
return;
}
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
_refreshCancellation = cancellation;
_refreshTask = RunRefreshLoopAsync(workflow, firstInterval, cancellation);
_refreshEpoch.Replace(
cancellation,
_ => new LegacyRefreshRuntimeStatus(
true,
null,
null,
false,
null,
string.Empty,
scheduler.CompletedRefreshCount,
scheduler.MaximumRefreshCount,
false));
_refreshTask = RunRefreshLoopAsync(workflow, scheduler, cancellation);
}
private void StopRefreshLoop()
{
var cancellation = Interlocked.Exchange(ref _refreshCancellation, null);
if (cancellation is not null)
private void StopRefreshLoop() =>
_refreshEpoch.Stop(status => status with
{
try
{
cancellation.Cancel();
}
catch (ObjectDisposedException)
{
}
}
}
IsActive = false,
NextAtUtc = null
});
private void ResetRefreshState()
{
StopRefreshLoop();
Interlocked.Exchange(ref _refreshCompletedCount, 0);
_refreshLimitReached = false;
_refreshMessage = string.Empty;
}
private void ResetRefreshState() =>
_refreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with
{
MaximumRefreshCount = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn
});
private async Task RunRefreshLoopAsync(
LegacyPlayoutWorkflow workflow,
TimeSpan firstInterval,
LegacyRefreshScheduler scheduler,
CancellationTokenSource cancellation)
{
var token = cancellation.Token;
var delay = firstInterval;
var maximum = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn;
try
{
while (!token.IsCancellationRequested)
while (true)
{
var engine = _playoutEngine;
if (engine is null)
if (engine is null ||
!ReferenceEquals(_playoutWorkflow, workflow) ||
!_refreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
if (maximum is { } cap && Volatile.Read(ref _refreshCompletedCount) >= cap)
if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
{
if (!await WaitForPlayCompletionAsync(engine, token)
.ConfigureAwait(false))
TrySetRefreshFault(
cancellation,
"PLAYOUT_OUTCOME_UNKNOWN",
"송출 상태가 불명확하여 자동 갱신을 시작하지 않았습니다.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
return;
}
if (scheduler.HasReachedMaximum)
{
if (!await scheduler.DrainMaximumCallbackAsync(
callbackToken => WaitForPlayCompletionAsync(
engine,
callbackToken),
token).ConfigureAwait(false))
{
_refreshMessage =
"자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. PGM을 확인하세요.";
TrySetRefreshFault(
cancellation,
"PLAY_CALLBACK_TIMEOUT",
"자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. 결과가 불명확하므로 PGM을 확인하세요.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
return;
}
_refreshLimitReached = true;
_refreshMessage = $"자동 갱신 상한 {cap}회에 도달했습니다.";
if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
{
TrySetRefreshFault(
cancellation,
"PLAY_CALLBACK_OUTCOME_UNKNOWN",
"마지막 재생 완료 상태가 불명확합니다. PGM과 Network Monitoring을 확인하세요.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
return;
}
_refreshEpoch.TryUpdate(
cancellation,
status => status with
{
IsActive = false,
NextAtUtc = null,
CompletedRefreshCount = scheduler.CompletedRefreshCount,
MaximumRefreshCount = scheduler.MaximumRefreshCount,
IsLimitReached = true,
Message = $"자동 갱신 상한 {scheduler.MaximumRefreshCount}회에 도달했습니다."
});
QueueOperatorState();
return;
}
if (!await WaitForPlayCompletionAsync(engine, token)
.ConfigureAwait(false))
if (!await scheduler.WaitForNextRefreshAsync(
callbackToken => WaitForPlayCompletionAsync(
engine,
callbackToken),
scheduledDelay =>
{
if (_refreshEpoch.TryUpdate(
cancellation,
status => status.IsActive
? status with
{
NextAtUtc = DateTimeOffset.UtcNow.Add(scheduledDelay)
}
: status))
{
QueueOperatorState();
}
},
token).ConfigureAwait(false))
{
_refreshMessage = "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다.";
TrySetRefreshFault(
cancellation,
"PLAY_CALLBACK_TIMEOUT",
"재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다. 결과가 불명확하므로 PGM을 확인하세요.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
return;
}
await Task.Delay(delay, token).ConfigureAwait(false);
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
if (!ReferenceEquals(_playoutWorkflow, workflow) ||
!_refreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
{
TrySetRefreshFault(
cancellation,
"PLAY_CALLBACK_OUTCOME_UNKNOWN",
"재생 완료 상태가 불명확하여 자동 갱신을 시작하지 않았습니다.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
return;
}
var intentGateEntered = false;
var playoutGateEntered = false;
try
{
// Match MainForm's single UI thread: Web DB work and the timer
// refresh are serialized in the same order as ProcessIntentAsync.
await _intentGate.WaitAsync(token).ConfigureAwait(false);
intentGateEntered = true;
_intentBusy = true;
QueueOperatorState();
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
playoutGateEntered = true;
Interlocked.Exchange(ref _playoutBusy, 1);
var result = await workflow.RefreshOnAirAsync(token).ConfigureAwait(false);
QueueOperatorState();
if (!_refreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0 ||
engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
{
if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
{
TrySetRefreshFault(
cancellation,
"REFRESH_OUTCOME_UNKNOWN",
"송출 상태가 불명확하여 자동 갱신을 시작하지 않았습니다.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
}
return;
}
var result = await scheduler.ExecuteRefreshAndTrackSuccessAsync(
workflow.RefreshOnAirAsync,
static refreshResult => refreshResult.IsSuccess,
token).ConfigureAwait(false);
if (!result.IsSuccess)
{
TrySetRefreshFault(
cancellation,
RefreshFaultCode(result.Code),
"자동 장면 갱신을 중단했습니다. TAKE OUT 전 실제 데이터와 PGM을 확인하세요.");
if (result.Code is PlayoutResultCode.OutcomeUnknown or
PlayoutResultCode.TimedOut)
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
}
_refreshMessage =
"자동 장면 갱신을 중단했습니다. TAKE OUT 전 PGM을 확인하세요.";
return;
}
Interlocked.Increment(ref _refreshCompletedCount);
delay = LegacySceneRefreshPolicy.RecurringInterval;
if (!_refreshEpoch.TryUpdate(
cancellation,
_ => new LegacyRefreshRuntimeStatus(
true,
null,
DateTimeOffset.UtcNow,
false,
null,
string.Empty,
scheduler.CompletedRefreshCount,
scheduler.MaximumRefreshCount,
false)))
{
return;
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
return;
}
catch (DatabaseInfrastructureException)
{
TrySetRefreshFault(
cancellation,
"DATABASE_UNAVAILABLE",
"자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
return;
}
catch (LegacySceneDataException)
{
TrySetRefreshFault(
cancellation,
"SCENE_DATA_INVALID",
"자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
return;
}
catch (ArgumentException)
{
TrySetRefreshFault(
cancellation,
"SCENE_ARGUMENT_INVALID",
"자동 장면 갱신 요청 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
return;
}
catch
{
TrySetRefreshFault(
cancellation,
"REFRESH_OUTCOME_UNKNOWN",
"자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
return;
}
finally
{
Interlocked.Exchange(ref _playoutBusy, 0);
_playoutCommandGate.Release();
if (playoutGateEntered)
{
Interlocked.Exchange(ref _playoutBusy, 0);
_playoutCommandGate.Release();
}
if (intentGateEntered)
{
_intentBusy = false;
_intentGate.Release();
}
QueueOperatorState();
}
}
@@ -724,21 +923,55 @@ public sealed partial class MainWindow
}
catch
{
TrySetRefreshFault(
cancellation,
"REFRESH_SCHEDULER_UNKNOWN",
"자동 갱신 예약 상태를 확인할 수 없습니다. 명령을 반복하지 마세요.");
await QuarantinePlayoutAsync().ConfigureAwait(false);
_refreshMessage = "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요.";
}
finally
{
if (ReferenceEquals(_refreshCancellation, cancellation))
{
Interlocked.CompareExchange(ref _refreshCancellation, null, cancellation);
}
var completedCurrentEpoch = _refreshEpoch.TryComplete(
cancellation,
status => status with
{
IsActive = false,
NextAtUtc = null
});
cancellation.Dispose();
QueueOperatorState();
if (completedCurrentEpoch)
{
QueueOperatorState();
}
}
}
private bool TrySetRefreshFault(
CancellationTokenSource cancellation,
string code,
string message) =>
_refreshEpoch.TryUpdate(
cancellation,
status => status with
{
IsActive = false,
NextAtUtc = null,
IsFaulted = true,
FaultCode = code,
Message = message
});
private static string RefreshFaultCode(PlayoutResultCode code) => code switch
{
PlayoutResultCode.OutcomeUnknown => "OUTCOME_UNKNOWN",
PlayoutResultCode.TimedOut => "TIMED_OUT",
PlayoutResultCode.Rejected => "REJECTED",
PlayoutResultCode.Cancelled => "CANCELLED",
PlayoutResultCode.Unavailable => "UNAVAILABLE",
PlayoutResultCode.Failed => "FAILED",
_ => "REFRESH_FAILED"
};
private static async Task<bool> WaitForPlayCompletionAsync(
IPlayoutEngine engine,
CancellationToken cancellationToken)
@@ -886,4 +1119,27 @@ public sealed partial class MainWindow
// outcome-unknown session quarantined and will not send a duplicate BYE.
}
}
private sealed record LegacyRefreshRuntimeStatus(
bool IsActive,
DateTimeOffset? NextAtUtc,
DateTimeOffset? LastSuccessAtUtc,
bool IsFaulted,
string? FaultCode,
string Message,
int CompletedRefreshCount,
int? MaximumRefreshCount,
bool IsLimitReached)
{
internal static LegacyRefreshRuntimeStatus Empty { get; } = new(
false,
null,
null,
false,
null,
string.Empty,
0,
null,
false);
}
}