1360 lines
52 KiB
C#
1360 lines
52 KiB
C#
using MBN_STOCK_WEBVIEW.Core.Playout;
|
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
|
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
|
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
|
using MBN_STOCK_WEBVIEW.Playout;
|
|
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
|
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
|
using Microsoft.UI.Xaml;
|
|
using Windows.Storage.Pickers;
|
|
using WinRT.Interop;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
|
|
|
public sealed partial class MainWindow
|
|
{
|
|
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
|
|
private IPlayoutEngine? _playoutEngine;
|
|
private LegacyPlayoutWorkflow? _playoutWorkflow;
|
|
private PlayoutOptions? _playoutOptions;
|
|
private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
|
|
private string? _playoutInitializationError;
|
|
private string? _playoutInitializationWarning;
|
|
private Task? _refreshTask;
|
|
private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _refreshEpoch =
|
|
new(LegacyRefreshRuntimeStatus.Empty);
|
|
private int _playoutBusy;
|
|
private int _playoutQuarantined;
|
|
private int _playoutShutdownStarted;
|
|
private int _operatorMutationQuarantined;
|
|
|
|
private void InitializePlayoutRuntime()
|
|
{
|
|
try
|
|
{
|
|
var explicitLocalConfigurationExists = File.Exists(
|
|
PlayoutOptionsLoader.DefaultPath);
|
|
_playoutOptions = PlayoutOptionsLoader.Load();
|
|
ResetRefreshState();
|
|
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
|
|
_playoutOptions,
|
|
explicitLocalConfigurationExists);
|
|
var initialComposition = startupComposition.Composition;
|
|
_playoutInitializationWarning = startupComposition.HasWarning
|
|
? startupComposition.WarningMessage
|
|
: null;
|
|
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
|
|
initialComposition);
|
|
_playoutEngine = PlayoutEngineFactory.Create(
|
|
_playoutOptions,
|
|
_playoutLaunchAuthorization);
|
|
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
|
|
if (_databaseRuntime is not null)
|
|
{
|
|
_playoutWorkflow = global::MBN_STOCK_WEBVIEW.LegacySceneRuntimeFactory.Create(
|
|
_playoutEngine,
|
|
_databaseRuntime.Executor,
|
|
compositionOptionsSource: _compositionOptions,
|
|
trustedManualSource: _manualNetSellStore,
|
|
trustedViSource: _manualViStore);
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_playoutInitializationWarning = null;
|
|
_playoutInitializationError = exception is PlayoutConfigurationException
|
|
? exception.Message
|
|
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
|
|
}
|
|
}
|
|
|
|
private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
|
|
{
|
|
var engine = _playoutEngine;
|
|
if (engine is null)
|
|
{
|
|
QueueOperatorState();
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
|
if (!result.IsSuccess)
|
|
{
|
|
_playoutInitializationWarning = null;
|
|
_playoutInitializationError = result.Message;
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
}
|
|
catch
|
|
{
|
|
_playoutInitializationWarning = null;
|
|
_playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
|
|
}
|
|
|
|
QueueOperatorState();
|
|
}
|
|
|
|
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
|
|
{
|
|
_playoutWorkflow?.ObserveEngineStatus(args.Current);
|
|
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
|
|
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
|
|
{
|
|
if (args.Current.State == PlayoutConnectionState.OutcomeUnknown ||
|
|
Volatile.Read(ref _playoutQuarantined) != 0)
|
|
{
|
|
StopRefreshLoop();
|
|
}
|
|
else
|
|
{
|
|
ResetRefreshState();
|
|
}
|
|
}
|
|
|
|
QueueOperatorState();
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
|
|
LegacyOperatorPlayoutCommand command,
|
|
CancellationToken cancellationToken,
|
|
bool gateAPrepareClaimed = false,
|
|
LegacyOperatorPlayoutRequest? gateAPrepareRequest = null)
|
|
{
|
|
if (_playoutLaunchAuthorization.IsGateA &&
|
|
(!gateAPrepareClaimed ||
|
|
command != LegacyOperatorPlayoutCommand.Prepare ||
|
|
gateAPrepareRequest is null))
|
|
{
|
|
return _controller.ReportError(
|
|
"Gate A 검증 회차에서는 승인된 5001 PREPARE 한 번만 실행할 수 있습니다.");
|
|
}
|
|
|
|
var engine = _playoutEngine;
|
|
var workflow = _playoutWorkflow;
|
|
if (engine is null || workflow is null)
|
|
{
|
|
return _controller.ReportError(
|
|
_playoutInitializationError ?? "Tornado 송출 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
if (Volatile.Read(ref _playoutQuarantined) != 0 ||
|
|
engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
|
|
{
|
|
return _controller.ReportError(
|
|
"송출 결과를 확인할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 앱을 다시 시작하세요.");
|
|
}
|
|
|
|
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
return _controller.ReportError("다른 송출 명령을 처리하고 있습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
Interlocked.Exchange(ref _playoutBusy, 1);
|
|
var nextKindBefore = workflow.State.NextKind;
|
|
var pageNavigationEnabledBefore = workflow.State.IsPageNavigationEnabled;
|
|
if (engine.Status.IsTakeOutCompletionPending)
|
|
{
|
|
return _controller.ReportError(
|
|
"Tornado TAKE OUT 완료 이벤트를 기다리는 동안에는 송출 명령을 실행할 수 없습니다.");
|
|
}
|
|
|
|
if (command != LegacyOperatorPlayoutCommand.TakeOut &&
|
|
engine.Status.IsPlayCompletionPending)
|
|
{
|
|
return _controller.ReportError(
|
|
"Tornado 재생 완료 이벤트를 기다리는 중입니다. 결과가 확인될 때까지 TAKE OUT만 사용할 수 있습니다.");
|
|
}
|
|
|
|
StopRefreshLoop();
|
|
PlayoutResult result;
|
|
switch (command)
|
|
{
|
|
case LegacyOperatorPlayoutCommand.Prepare:
|
|
var request = gateAPrepareRequest ??
|
|
await CreateFreshPagedPlayoutRequestAsync(
|
|
workflow,
|
|
cancellationToken).ConfigureAwait(false);
|
|
result = await workflow.PrepareAsync(
|
|
request.Playlist,
|
|
request.SelectedIndexZeroBased,
|
|
cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
case LegacyOperatorPlayoutCommand.TakeIn:
|
|
// MainForm.btnTake_Click always copied m_rowidx (the operator's
|
|
// candidate) to m_crow and ran ONAirMode + Play, even while another
|
|
// row was already on air. Re-read the current selection on every F8;
|
|
// a stale explicit PREPARE must never override a later row click.
|
|
var takeInRequest = await CreateFreshPagedPlayoutRequestAsync(
|
|
workflow,
|
|
cancellationToken).ConfigureAwait(false);
|
|
result = await workflow.TakeSelectedAsync(
|
|
takeInRequest.Playlist,
|
|
takeInRequest.SelectedIndexZeroBased,
|
|
cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
case LegacyOperatorPlayoutCommand.Next:
|
|
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
case LegacyOperatorPlayoutCommand.TakeOut:
|
|
result = await workflow.TakeOutAsync(
|
|
PlayoutTakeOutScope.All,
|
|
cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(command));
|
|
}
|
|
|
|
if (result.IsSuccess &&
|
|
command != LegacyOperatorPlayoutCommand.TakeOut &&
|
|
workflow.State.CurrentEntryId is { Length: > 0 } currentEntryId &&
|
|
workflow.State.PageCount > 0)
|
|
{
|
|
// FarPoint column 5 was mutable operator state: F8/PREPARE reset a
|
|
// paged cut to 1/N, while Page NEXT left 2/N, 3/N, ... in the row.
|
|
// NEXT must not replace the independently selected F8 candidate.
|
|
_controller.ReflectSuccessfulPlayout(
|
|
currentEntryId,
|
|
workflow.State.PageIndexZeroBased,
|
|
workflow.State.PageCount,
|
|
synchronizeOperatorSelection:
|
|
command is LegacyOperatorPlayoutCommand.Prepare or
|
|
LegacyOperatorPlayoutCommand.TakeIn);
|
|
}
|
|
|
|
if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut)
|
|
{
|
|
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
|
return _controller.ReportError(
|
|
"송출 명령 결과가 불명확합니다. 명령을 반복하지 말고 PGM과 Network Monitoring을 확인하세요.");
|
|
}
|
|
|
|
if (!result.IsSuccess)
|
|
{
|
|
return _controller.ReportError(result.Message);
|
|
}
|
|
|
|
if (command == LegacyOperatorPlayoutCommand.TakeOut)
|
|
{
|
|
ResetRefreshState();
|
|
}
|
|
|
|
if (command == LegacyOperatorPlayoutCommand.TakeIn ||
|
|
command == LegacyOperatorPlayoutCommand.Next &&
|
|
nextKindBefore == LegacyWorkflowNextKind.PlaylistNext &&
|
|
!pageNavigationEnabledBefore)
|
|
{
|
|
StartRefreshLoop(workflow);
|
|
}
|
|
|
|
return _controller.ReportInformation(result.Message);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return _controller.ReportError("송출 요청이 취소되었습니다.");
|
|
}
|
|
catch (DatabaseOperationException exception)
|
|
{
|
|
return _controller.ReportError(exception.Message);
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is LegacySceneDataException or ArgumentException or InvalidOperationException)
|
|
{
|
|
return _controller.ReportError(exception.Message);
|
|
}
|
|
catch
|
|
{
|
|
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
|
return _controller.ReportError(
|
|
"송출 엔진 명령의 결과를 확인할 수 없습니다. 명령을 반복하지 마세요.");
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _playoutBusy, 0);
|
|
_playoutCommandGate.Release();
|
|
QueueOperatorState();
|
|
}
|
|
}
|
|
|
|
private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(
|
|
LegacyPlayoutWorkflow workflow,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var fadeDuration = _compositionOptions!.Current.FadeDuration;
|
|
var request = _controller.CreatePlayoutRequest(fadeDuration);
|
|
|
|
// The first PREPARE/TAKE IN freezes the complete playlist. Resolve every
|
|
// operator PageN row from a fresh read-only DB preflight before that snapshot
|
|
// is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the
|
|
// already-frozen 1/N values and must not query a second navigation contract.
|
|
if (workflow.State.CurrentEntryId is not null)
|
|
{
|
|
return request;
|
|
}
|
|
|
|
var pagedEntries = request.Playlist
|
|
.Where(static entry => entry.PageNavigation?.PageSize.HasValue == true)
|
|
.ToArray();
|
|
if (pagedEntries.Length == 0)
|
|
{
|
|
return request;
|
|
}
|
|
|
|
var plans = await workflow.PreflightPagePlansAsync(
|
|
Array.AsReadOnly(pagedEntries),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (plans.Count != pagedEntries.Length)
|
|
{
|
|
throw new LegacySceneDataException(
|
|
"The playlist page preflight did not return every PageN entry.");
|
|
}
|
|
|
|
foreach (var plan in plans)
|
|
{
|
|
var entry = pagedEntries.SingleOrDefault(candidate =>
|
|
string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal));
|
|
if (entry?.PageNavigation?.PageSize is not { } expectedPageSize ||
|
|
plan.PageSize != (int)expectedPageSize ||
|
|
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
|
|
{
|
|
throw new LegacySceneDataException(
|
|
"The playlist page preflight does not match the operator PageN row.");
|
|
}
|
|
|
|
_controller.ReflectSuccessfulPlayout(
|
|
plan.EntryId,
|
|
pageIndexZeroBased: 0,
|
|
pageCount: plan.PageCount,
|
|
synchronizeOperatorSelection: false);
|
|
}
|
|
|
|
return _controller.CreatePlayoutRequest(fadeDuration);
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
|
|
string capability,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!_playoutLaunchAuthorization.IsGateA ||
|
|
!_playoutLaunchAuthorization.TryClaimGateAPrepare(capability))
|
|
{
|
|
return _controller.ReportError(
|
|
"Gate A PREPARE 승인이 없거나 이미 사용되었습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!TryCreateExactGateAPrepareRequest(out var request))
|
|
{
|
|
return _controller.ReportError(
|
|
"Gate A PREPARE 직전 상태가 승인된 삼성전자 5001 page 1 조건과 일치하지 않습니다.");
|
|
}
|
|
|
|
return await ExecuteOperatorPlayoutAsync(
|
|
LegacyOperatorPlayoutCommand.Prepare,
|
|
cancellationToken,
|
|
gateAPrepareClaimed: true,
|
|
gateAPrepareRequest: request).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
// Success, rejection, cancellation and unknown outcome all consume the
|
|
// one process-lifetime capability. There is deliberately no retry path.
|
|
_playoutLaunchAuthorization.CompleteGateAPrepare();
|
|
}
|
|
}
|
|
|
|
private bool TryCreateExactGateAPrepareRequest(
|
|
out LegacyOperatorPlayoutRequest? request)
|
|
{
|
|
request = null;
|
|
var snapshot = _controller.Current;
|
|
if (snapshot.Playlist.Count != 1 ||
|
|
_compositionOptions?.Current is not { } composition ||
|
|
composition.FadeDuration != 6 ||
|
|
composition.BackgroundKind != LegacySceneBackgroundKind.None)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var row = snapshot.Playlist[0];
|
|
if (!row.IsEnabled || !row.IsActive ||
|
|
!string.Equals(row.MarketText, "코스피", StringComparison.Ordinal) ||
|
|
!string.Equals(row.StockName, "삼성전자", StringComparison.Ordinal) ||
|
|
!string.Equals(row.GraphicType, "1열판기본", StringComparison.Ordinal) ||
|
|
!string.Equals(row.Subtype, "현재가", StringComparison.Ordinal) ||
|
|
!string.Equals(row.PageText, "1/1", StringComparison.Ordinal) ||
|
|
!string.Equals(row.StockCode, "005930", StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
LegacyOperatorPlayoutRequest candidate;
|
|
try
|
|
{
|
|
candidate = _controller.CreatePlayoutRequest(6);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (candidate.SelectedIndexZeroBased != 0 ||
|
|
candidate.Playlist.Count != 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var entry = candidate.Playlist[0];
|
|
var selection = entry.Selection;
|
|
if (!entry.IsEnabled ||
|
|
!string.Equals(entry.EntryId, row.RowId, StringComparison.Ordinal) ||
|
|
!string.Equals(entry.CutCode, "5001", StringComparison.Ordinal) ||
|
|
entry.FadeDuration != 6 ||
|
|
entry.PageNavigation?.IsEnabled == true ||
|
|
entry.MovingAverageSelectionSource is not null ||
|
|
selection is null ||
|
|
!string.Equals(selection.GroupCode, "코스피", StringComparison.Ordinal) ||
|
|
!string.Equals(selection.Subject, "삼성전자", StringComparison.Ordinal) ||
|
|
!string.Equals(selection.GraphicType, "1열판기본", StringComparison.Ordinal) ||
|
|
!string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) ||
|
|
!string.Equals(selection.DataCode, "005930", StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
request = candidate;
|
|
return true;
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(
|
|
int fadeDuration,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
|
|
{
|
|
return _controller.ReportError(
|
|
"Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다.");
|
|
}
|
|
|
|
if (fadeDuration is < 0 or > 19)
|
|
{
|
|
return _controller.ReportError("DissolveTime 값이 올바르지 않습니다.");
|
|
}
|
|
|
|
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
return _controller.ReportError("송출 명령 처리 중에는 Fade를 변경할 수 없습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!CanChangeComposition())
|
|
{
|
|
return _controller.ReportError("DissolveTime은 IDLE 상태에서만 변경할 수 있습니다.");
|
|
}
|
|
|
|
var source = _compositionOptions!;
|
|
source.Update(source.Current with { FadeDuration = fadeDuration });
|
|
return _controller.ReportInformation(
|
|
$"DissolveTime {fadeDuration + 1}을 다음 PREPARE부터 적용합니다.");
|
|
}
|
|
finally
|
|
{
|
|
_playoutCommandGate.Release();
|
|
}
|
|
}
|
|
|
|
private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(bool last)
|
|
{
|
|
// MainForm.ProcessCmdKey stopped timer1 before applying either Home or End.
|
|
// Ordinary row selection deliberately does not use this path.
|
|
StopRefreshLoop();
|
|
return _controller.SelectPlaylistBoundary(last);
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
|
|
{
|
|
return _controller.ReportError(
|
|
"Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다.");
|
|
}
|
|
|
|
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!CanChangeComposition())
|
|
{
|
|
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
|
|
}
|
|
|
|
var source = _compositionOptions!;
|
|
if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None)
|
|
{
|
|
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
|
|
_playoutOptions!,
|
|
source.Current.FadeDuration));
|
|
_playoutInitializationWarning = null;
|
|
return _controller.ReportInformation(
|
|
"기본 배경을 다음 PREPARE부터 적용합니다.");
|
|
}
|
|
|
|
source.Update(new LegacySceneCueCompositionOptions(
|
|
source.Current.FadeDuration,
|
|
LegacySceneBackgroundKind.None));
|
|
return _controller.ReportInformation("배경 사용을 해제했습니다.");
|
|
}
|
|
catch (PlayoutConfigurationException exception)
|
|
{
|
|
return _controller.ReportError(exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
_playoutCommandGate.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
|
|
{
|
|
return _controller.ReportError(
|
|
"Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다.");
|
|
}
|
|
|
|
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!CanChangeComposition())
|
|
{
|
|
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
|
|
}
|
|
|
|
// MainForm.btnback_Click assigns 배경\기본.vrv before opening the
|
|
// picker. Preserve that observable behavior when the trusted default is
|
|
// present, while still allowing the operator to choose another asset if it
|
|
// is not.
|
|
var source = _compositionOptions!;
|
|
try
|
|
{
|
|
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
|
|
_playoutOptions!,
|
|
source.Current.FadeDuration));
|
|
_playoutInitializationWarning = null;
|
|
}
|
|
catch (PlayoutConfigurationException)
|
|
{
|
|
}
|
|
|
|
var picker = new FileOpenPicker
|
|
{
|
|
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
|
|
ViewMode = PickerViewMode.List
|
|
};
|
|
foreach (var extension in new[] { ".vrv", ".jpg", ".jpeg", ".png" })
|
|
{
|
|
picker.FileTypeFilter.Add(extension);
|
|
}
|
|
|
|
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
|
|
var selected = await picker.PickSingleFileAsync();
|
|
if (selected is null)
|
|
{
|
|
return _controller.Current;
|
|
}
|
|
|
|
if (!CanChangeComposition())
|
|
{
|
|
return _controller.ReportError(
|
|
"파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다.");
|
|
}
|
|
|
|
source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection(
|
|
_playoutOptions!,
|
|
source.Current.FadeDuration,
|
|
selected.Path));
|
|
_playoutInitializationWarning = null;
|
|
return _controller.ReportInformation(
|
|
"선택한 배경을 다음 PREPARE부터 적용합니다.");
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is ArgumentException or IOException or UnauthorizedAccessException or
|
|
PlayoutConfigurationException)
|
|
{
|
|
return _controller.ReportError(
|
|
"신뢰 배경 폴더 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다.");
|
|
}
|
|
finally
|
|
{
|
|
_playoutCommandGate.Release();
|
|
}
|
|
}
|
|
|
|
private bool CanChangeComposition()
|
|
{
|
|
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 &&
|
|
Volatile.Read(ref _playoutQuarantined) == 0 &&
|
|
!status.IsPlayCompletionPending &&
|
|
!status.IsTakeOutCompletionPending &&
|
|
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
|
|
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
|
|
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
|
|
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
|
|
!refresh.IsActive &&
|
|
(_refreshTask is null || _refreshTask.IsCompleted);
|
|
}
|
|
|
|
private bool CanAcceptOperatorMutation()
|
|
{
|
|
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
|
|
// from composing a playlist. PREPARE will still fail closed later.
|
|
return true;
|
|
}
|
|
|
|
return Volatile.Read(ref _playoutBusy) == 0 &&
|
|
Volatile.Read(ref _playoutQuarantined) == 0 &&
|
|
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
|
|
status.State != PlayoutConnectionState.OutcomeUnknown &&
|
|
!status.IsPlayCompletionPending &&
|
|
!status.IsTakeOutCompletionPending &&
|
|
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
|
|
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
|
|
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
|
|
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
|
|
!refresh.IsActive &&
|
|
(_refreshTask is null || _refreshTask.IsCompleted);
|
|
}
|
|
|
|
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
|
|
LegacyCutPointerDownIntent { AllowAppend: true } or
|
|
LegacyDropSelectedCutsIntent or
|
|
LegacySetPlaylistEnabledIntent or
|
|
LegacySetAllPlaylistEnabledIntent or
|
|
LegacyMoveSelectedPlaylistRowsIntent or
|
|
LegacyReorderPlaylistRowsIntent or
|
|
LegacyDeleteSelectedPlaylistRowsIntent or
|
|
LegacyDeleteAllPlaylistRowsIntent or
|
|
LegacyToggleActivePlaylistEnabledIntent or
|
|
LegacyActivateFixedActionIntent or
|
|
LegacyActivateFixedSectionIntent or
|
|
LegacyActivateIndustryActionIntent or
|
|
LegacyActivateIndustrySectionIntent or
|
|
LegacyActivateOverseasActionIntent or
|
|
LegacyAddComparisonPairIntent or
|
|
LegacyMoveComparisonPairIntent or
|
|
LegacyDeleteSelectedComparisonPairIntent or
|
|
LegacyDeleteAllComparisonPairsIntent or
|
|
LegacyActivateComparisonActionIntent or
|
|
LegacyActivateComparisonSectionIntent or
|
|
LegacyActivateThemeActionIntent or
|
|
LegacyActivateThemeResultIntent or
|
|
LegacyActivateExpertActionIntent or
|
|
LegacyActivateTradingHaltActionIntent or
|
|
LegacyBeginUc4ThemeCatalogIntent or
|
|
LegacyEditUc4SelectedThemeIntent or
|
|
LegacyDeleteUc4SelectedThemeIntent or
|
|
LegacyBeginUc6ExpertCatalogIntent or
|
|
LegacyEditUc6SelectedExpertIntent or
|
|
LegacyDeleteUc6SelectedExpertIntent or
|
|
LegacyBeginCreateThemeCatalogIntent or
|
|
LegacyBeginCreateExpertCatalogIntent or
|
|
LegacyAddOperatorCatalogStockIntent or
|
|
LegacyActivateOperatorCatalogStockIntent or
|
|
LegacyMoveOperatorCatalogDraftRowIntent or
|
|
LegacyReorderOperatorCatalogDraftRowIntent or
|
|
LegacyRemoveOperatorCatalogDraftRowIntent or
|
|
LegacyRemoveActiveOperatorCatalogDraftRowIntent or
|
|
LegacyClearOperatorCatalogDraftRowsIntent or
|
|
LegacySetOperatorCatalogDraftBuyAmountIntent or
|
|
LegacySaveOperatorCatalogIntent or
|
|
LegacyConfirmNativeDialogIntent or
|
|
LegacyLoadSelectedNamedPlaylistIntent or
|
|
LegacyLoadNamedPlaylistByIdIntent or
|
|
LegacyCreateNamedPlaylistIntent or
|
|
LegacySaveCurrentNamedPlaylistIntent or
|
|
LegacySaveCurrentNamedPlaylistToIntent or
|
|
LegacyDeleteSelectedNamedPlaylistIntent or
|
|
LegacySaveManualFinancialIntent or
|
|
LegacyDeleteManualFinancialIntent or
|
|
LegacyDeleteAllManualFinancialIntent or
|
|
LegacyActivateManualFinancialActionIntent or
|
|
LegacyActivateManualFinancialResultIntent or
|
|
LegacySetManualNetSellCellIntent or
|
|
LegacySaveManualNetSellIntent or
|
|
LegacyAddManualNetSellCutIntent or
|
|
LegacyAddManualViResultIntent or
|
|
LegacyMoveManualViItemIntent or
|
|
LegacyDeleteManualViItemIntent or
|
|
LegacyDeleteAllManualViItemsIntent or
|
|
LegacySaveManualViIntent or
|
|
LegacyAddManualViCutIntent or
|
|
LegacyConfirmManualListIntent or
|
|
LegacyImportManualListsIntent;
|
|
|
|
private static bool IsPersistentWriteIntent(LegacyUiIntent intent) => intent is
|
|
LegacyDeleteUc4SelectedThemeIntent or
|
|
LegacyDeleteUc6SelectedExpertIntent or
|
|
LegacySaveOperatorCatalogIntent or
|
|
LegacyConfirmNativeDialogIntent or
|
|
LegacyAddComparisonPairIntent or
|
|
LegacyMoveComparisonPairIntent or
|
|
LegacyDeleteSelectedComparisonPairIntent or
|
|
LegacyDeleteAllComparisonPairsIntent or
|
|
LegacySaveManualFinancialIntent or
|
|
LegacyDeleteManualFinancialIntent or
|
|
LegacyDeleteAllManualFinancialIntent or
|
|
LegacySaveManualNetSellIntent or
|
|
LegacySaveManualViIntent or
|
|
LegacyConfirmManualListIntent or
|
|
LegacyImportManualListsIntent or
|
|
LegacyCreateNamedPlaylistIntent or
|
|
LegacySaveCurrentNamedPlaylistIntent or
|
|
LegacySaveCurrentNamedPlaylistToIntent or
|
|
LegacyDeleteSelectedNamedPlaylistIntent;
|
|
|
|
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
|
|
LegacySetManualNetSellCellIntent or
|
|
LegacyAddManualViResultIntent or
|
|
LegacyMoveManualViItemIntent or
|
|
LegacyDeleteManualViItemIntent or
|
|
LegacyDeleteAllManualViItemsIntent or
|
|
LegacyConfirmManualListIntent or
|
|
LegacyImportManualListsIntent;
|
|
|
|
private void ObserveOperatorMutationQuarantine(LegacyOperatorSnapshot snapshot)
|
|
{
|
|
if (snapshot.ManualFinancial?.WritesQuarantined == true ||
|
|
snapshot.NamedPlaylist?.IsWriteQuarantined == true ||
|
|
snapshot.ManualLists?.WritesQuarantined == true ||
|
|
snapshot.OperatorCatalog?.WritesQuarantined == true)
|
|
{
|
|
Interlocked.Exchange(ref _operatorMutationQuarantined, 1);
|
|
}
|
|
}
|
|
|
|
private bool IsOperatorMutationQuarantined() =>
|
|
Volatile.Read(ref _operatorMutationQuarantined) != 0;
|
|
|
|
private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot()
|
|
{
|
|
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 ||
|
|
status?.State == PlayoutConnectionState.OutcomeUnknown;
|
|
var phase = !string.IsNullOrWhiteSpace(status?.OnAirSceneName)
|
|
? LegacyOperatorPlayoutPhase.Program
|
|
: !string.IsNullOrWhiteSpace(status?.PreparedSceneName)
|
|
? LegacyOperatorPlayoutPhase.Prepared
|
|
: LegacyOperatorPlayoutPhase.Idle;
|
|
var backgroundEnabled =
|
|
composition.BackgroundKind != LegacySceneBackgroundKind.None;
|
|
var snapshot = new LegacyOperatorPlayoutSnapshot(
|
|
status?.Mode ?? PlayoutMode.Disabled,
|
|
quarantined
|
|
? PlayoutConnectionState.OutcomeUnknown
|
|
: status?.State ?? PlayoutConnectionState.Disabled,
|
|
phase,
|
|
status?.IsProcessRunning ?? false,
|
|
status?.IsConnected ?? false,
|
|
(status?.IsCommandAvailable ?? false) && !quarantined,
|
|
(status?.LiveTakeInAllowed ?? false) && !quarantined,
|
|
status?.IsPlayCompletionPending ?? false,
|
|
quarantined,
|
|
status?.PreparedSceneName,
|
|
status?.OnAirSceneName,
|
|
workflow?.CurrentCueIndexZeroBased ?? -1,
|
|
workflow?.CurrentEntryId,
|
|
workflow?.BuilderKey,
|
|
workflow?.PageIndexZeroBased ?? 0,
|
|
workflow?.PageCount ?? 0,
|
|
workflow?.PageSize ?? 0,
|
|
workflow?.ItemCount ?? 0,
|
|
workflow?.CurrentPageItemCount ?? 0,
|
|
workflow?.IsLastPage ?? true,
|
|
workflow?.NextKind ?? LegacyWorkflowNextKind.None,
|
|
Volatile.Read(ref _playoutBusy) != 0,
|
|
refresh.IsActive,
|
|
refresh.CompletedRefreshCount,
|
|
refresh.MaximumRefreshCount,
|
|
refresh.IsLimitReached,
|
|
composition.FadeDuration,
|
|
backgroundEnabled,
|
|
backgroundEnabled
|
|
? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty
|
|
: string.Empty,
|
|
CanChangeComposition(),
|
|
quarantined
|
|
? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
|
|
: refresh.Message.Length > 0
|
|
? refresh.Message
|
|
: _playoutInitializationWarning ?? status?.Message ??
|
|
_playoutInitializationError ??
|
|
"송출 어댑터를 사용할 수 없습니다.");
|
|
return snapshot with
|
|
{
|
|
IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false,
|
|
RefreshNextAtUtc = refresh.NextAtUtc,
|
|
RefreshLastSuccessAtUtc = refresh.LastSuccessAtUtc,
|
|
RefreshFaultCode = refresh.FaultCode
|
|
};
|
|
}
|
|
|
|
private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
|
|
{
|
|
ResetRefreshState();
|
|
var cutCode = workflow.State.OnAirCutCode;
|
|
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) ||
|
|
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);
|
|
_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() =>
|
|
_refreshEpoch.Stop(status => status with
|
|
{
|
|
IsActive = false,
|
|
NextAtUtc = null
|
|
});
|
|
|
|
private void ResetRefreshState() =>
|
|
_refreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with
|
|
{
|
|
MaximumRefreshCount = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn
|
|
});
|
|
|
|
private async Task RunRefreshLoopAsync(
|
|
LegacyPlayoutWorkflow workflow,
|
|
LegacyRefreshScheduler scheduler,
|
|
CancellationTokenSource cancellation)
|
|
{
|
|
var token = cancellation.Token;
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
var engine = _playoutEngine;
|
|
if (engine is null ||
|
|
!ReferenceEquals(_playoutWorkflow, workflow) ||
|
|
!_refreshEpoch.IsCurrent(cancellation) ||
|
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
|
|
{
|
|
TrySetRefreshFault(
|
|
cancellation,
|
|
"PLAYOUT_OUTCOME_UNKNOWN",
|
|
"송출 상태가 불명확하여 자동 갱신을 시작하지 않았습니다.");
|
|
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (scheduler.HasReachedMaximum)
|
|
{
|
|
if (!await scheduler.DrainMaximumCallbackAsync(
|
|
callbackToken => WaitForPlayCompletionAsync(
|
|
engine,
|
|
callbackToken),
|
|
token).ConfigureAwait(false))
|
|
{
|
|
TrySetRefreshFault(
|
|
cancellation,
|
|
"PLAY_CALLBACK_TIMEOUT",
|
|
"자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. 결과가 불명확하므로 PGM을 확인하세요.");
|
|
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
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 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))
|
|
{
|
|
TrySetRefreshFault(
|
|
cancellation,
|
|
"PLAY_CALLBACK_TIMEOUT",
|
|
"재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다. 결과가 불명확하므로 PGM을 확인하세요.");
|
|
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
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 databaseGateEntered = 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 AcquirePriorityDatabaseActivityAsync(token).ConfigureAwait(false);
|
|
databaseGateEntered = true;
|
|
|
|
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
|
|
playoutGateEntered = true;
|
|
Interlocked.Exchange(ref _playoutBusy, 1);
|
|
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);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
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
|
|
{
|
|
if (playoutGateEntered)
|
|
{
|
|
Interlocked.Exchange(ref _playoutBusy, 0);
|
|
_playoutCommandGate.Release();
|
|
}
|
|
|
|
if (databaseGateEntered)
|
|
{
|
|
_databaseActivityGate.Release();
|
|
}
|
|
|
|
if (intentGateEntered)
|
|
{
|
|
_intentBusy = false;
|
|
_intentGate.Release();
|
|
}
|
|
|
|
QueueOperatorState();
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
|
{
|
|
}
|
|
catch
|
|
{
|
|
TrySetRefreshFault(
|
|
cancellation,
|
|
"REFRESH_SCHEDULER_UNKNOWN",
|
|
"자동 갱신 예약 상태를 확인할 수 없습니다. 명령을 반복하지 마세요.");
|
|
await QuarantinePlayoutAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
var completedCurrentEpoch = _refreshEpoch.TryComplete(
|
|
cancellation,
|
|
status => status with
|
|
{
|
|
IsActive = false,
|
|
NextAtUtc = null
|
|
});
|
|
cancellation.Dispose();
|
|
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)
|
|
{
|
|
if (!engine.Status.IsPlayCompletionPending)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var timeout = Math.Clamp(
|
|
engine.Status.OperationTimeoutMilliseconds,
|
|
100,
|
|
300_000);
|
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
linked.CancelAfter(timeout);
|
|
while (engine.Status.IsPlayCompletionPending)
|
|
{
|
|
var completion = new TaskCompletionSource(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
void OnStatus(object? sender, PlayoutStatusChangedEventArgs args)
|
|
{
|
|
if (!args.Current.IsPlayCompletionPending)
|
|
{
|
|
completion.TrySetResult();
|
|
}
|
|
}
|
|
|
|
engine.StatusChanged += OnStatus;
|
|
try
|
|
{
|
|
if (!engine.Status.IsPlayCompletionPending)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
engine.StatusChanged -= OnStatus;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private async ValueTask QuarantinePlayoutAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _playoutQuarantined, 1) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
StopRefreshLoop();
|
|
if (_playoutEngine is not null)
|
|
{
|
|
try
|
|
{
|
|
await _playoutEngine.QuarantineAsync().ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
private void QueueOperatorState()
|
|
{
|
|
if (_closing || !_webViewReady)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DispatcherQueue.TryEnqueue(() =>
|
|
{
|
|
if (!_closing)
|
|
{
|
|
PostState(_controller.Current);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task ShutdownPlayoutRuntimeAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
StopRefreshLoop();
|
|
var refresh = _refreshTask;
|
|
if (refresh is not null)
|
|
{
|
|
try
|
|
{
|
|
await refresh.WaitAsync(TimeSpan.FromSeconds(2));
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
var engine = _playoutEngine;
|
|
_playoutEngine = null;
|
|
_playoutWorkflow = null;
|
|
if (engine is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
engine.StatusChanged -= OnPlayoutStatusChanged;
|
|
try
|
|
{
|
|
if (Volatile.Read(ref _playoutQuarantined) == 0 &&
|
|
engine.Status.State != PlayoutConnectionState.OutcomeUnknown &&
|
|
string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) &&
|
|
!engine.Status.IsPlayCompletionPending &&
|
|
!engine.Status.IsTakeOutCompletionPending)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
await engine.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
private void ShutdownPlayoutRuntime()
|
|
{
|
|
try
|
|
{
|
|
ShutdownPlayoutRuntimeAsync()
|
|
.Wait(TimeSpan.FromSeconds(8));
|
|
}
|
|
catch
|
|
{
|
|
// Window shutdown is bounded and best-effort. The engine itself keeps an
|
|
// 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);
|
|
}
|
|
}
|