Clarify unavailable schedule data

This commit is contained in:
2026-05-14 12:00:33 +09:00
parent aa2336358b
commit 8beee8e419
6 changed files with 349 additions and 15 deletions

View File

@@ -205,6 +205,61 @@
</Border> </Border>
</Grid> </Grid>
<Border
Padding="14"
Background="{x:Bind ViewModel.ScheduleDataIssueBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind ViewModel.ScheduleDataIssueBorderBrush, Mode=OneWay}"
BorderThickness="2"
CornerRadius="18"
Visibility="{x:Bind ViewModel.ScheduleDataIssueVisibility, Mode=OneWay}">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="42"
Height="42"
Background="#52FFB81C"
BorderBrush="#FFFFB81C"
BorderThickness="1"
CornerRadius="21">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="Bahnschrift SemiBold"
FontSize="22"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="!" />
</Border>
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="데이터 없음 알림" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="18"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.ScheduleDataIssueTitle, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.ScheduleDataIssueMessage, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.ScheduleDataIssueDetail, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.ScheduleDataIssueHint, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Grid>
</Border>
<Border <Border
Padding="16" Padding="16"
Background="#0C1421" Background="#0C1421"
@@ -887,6 +942,49 @@
<TextBlock <TextBlock
Style="{StaticResource ConsoleLabelTextStyle}" Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" /> Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" />
<Border
Padding="10"
Background="{x:Bind IssueBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind IssueBorderBrush, Mode=OneWay}"
BorderThickness="1"
CornerRadius="8"
Visibility="{x:Bind IssueVisibility, Mode=OneWay}">
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind IssueTitle, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind LastIssueLabel, Mode=OneWay}" />
</StackPanel>
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind IssueDetail, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind IssueOperatorHint, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<Button
Grid.Column="1"
Click="RetryDataIssueButton_Click"
Content="다시 확인"
Style="{StaticResource PanelCommandButtonStyle}"
VerticalAlignment="Center"
Visibility="{x:Bind DataUnavailableActionVisibility, Mode=OneWay}" />
</Grid>
</Border>
<StackPanel <StackPanel
Orientation="Horizontal" Orientation="Horizontal"
Spacing="8"> Spacing="8">

View File

@@ -64,6 +64,18 @@ public sealed partial class ChannelSchedulePanel : UserControl
command.Execute(item); command.Execute(item);
} }
private void RetryDataIssueButton_Click(object sender, RoutedEventArgs e)
{
var item = GetItem(sender);
var command = ViewModel?.RetryDataIssueCommand;
if (item is null || command is null || !command.CanExecute(item))
{
return;
}
command.Execute(item);
}
private void IncreaseDurationButton_Click(object sender, RoutedEventArgs e) private void IncreaseDurationButton_Click(object sender, RoutedEventArgs e)
{ {
GetItem(sender)?.StepDraftDuration(1d); GetItem(sender)?.StepDraftDuration(1d);

View File

@@ -2,6 +2,7 @@ using System;
using System.Linq; using System.Linq;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.UI; using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media;
using Tornado3_2026Election.Common; using Tornado3_2026Election.Common;
using Tornado3_2026Election.Services; using Tornado3_2026Election.Services;
@@ -12,6 +13,7 @@ public sealed class ChannelScheduleItem : ObservableObject
{ {
private ScheduleQueueItemState _state = ScheduleQueueItemState.Queued; private ScheduleQueueItemState _state = ScheduleQueueItemState.Queued;
private string _lastError = string.Empty; private string _lastError = string.Empty;
private DateTimeOffset? _lastIssueAt;
private DateTimeOffset? _lastPlayedAt; private DateTimeOffset? _lastPlayedAt;
private string _currentRegionLabel = string.Empty; private string _currentRegionLabel = string.Empty;
private double _defaultCutDurationSeconds; private double _defaultCutDurationSeconds;
@@ -103,6 +105,7 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(StateBadgeBackgroundBrush)); OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
OnPropertyChanged(nameof(CardOpacity)); OnPropertyChanged(nameof(CardOpacity));
OnPropertyChanged(nameof(CanDelete)); OnPropertyChanged(nameof(CanDelete));
OnIssueStateChanged();
} }
} }
} }
@@ -110,7 +113,26 @@ public sealed class ChannelScheduleItem : ObservableObject
public string LastError public string LastError
{ {
get => _lastError; get => _lastError;
set => SetProperty(ref _lastError, value); set
{
if (SetProperty(ref _lastError, value))
{
LastIssueAt = string.IsNullOrWhiteSpace(value) ? null : DateTimeOffset.Now;
OnIssueStateChanged();
}
}
}
public DateTimeOffset? LastIssueAt
{
get => _lastIssueAt;
private set
{
if (SetProperty(ref _lastIssueAt, value))
{
OnPropertyChanged(nameof(LastIssueLabel));
}
}
} }
public DateTimeOffset? LastPlayedAt public DateTimeOffset? LastPlayedAt
@@ -139,6 +161,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Sending => "준비", ScheduleQueueItemState.Sending => "준비",
ScheduleQueueItemState.OnAir => "송출 중", ScheduleQueueItemState.OnAir => "송출 중",
ScheduleQueueItemState.Completed => "대기", ScheduleQueueItemState.Completed => "대기",
ScheduleQueueItemState.DataUnavailable => "데이터 없음",
ScheduleQueueItemState.Error => "오류", ScheduleQueueItemState.Error => "오류",
_ => "대기" _ => "대기"
}; };
@@ -149,6 +172,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28), ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38), ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68), ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133), ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
_ => ColorHelper.FromArgb(255, 100, 116, 139) _ => ColorHelper.FromArgb(255, 100, 116, 139)
}); });
@@ -159,6 +183,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 72, 38, 10), ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 72, 38, 10),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 64, 42, 16), ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 64, 42, 16),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 58, 22, 24), ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 58, 22, 24),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 63, 40, 16),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 54, 18, 31), ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 54, 18, 31),
_ => ColorHelper.FromArgb(255, 18, 32, 51) _ => ColorHelper.FromArgb(255, 18, 32, 51)
}); });
@@ -169,6 +194,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28), ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38), ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 255, 90, 84), ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 255, 90, 84),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133), ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
_ => ColorHelper.FromArgb(255, 39, 64, 95) _ => ColorHelper.FromArgb(255, 39, 64, 95)
}); });
@@ -179,6 +205,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 194, 65, 12), ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 194, 65, 12),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 180, 83, 9), ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 180, 83, 9),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 220, 38, 38), ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 220, 38, 38),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 180, 83, 9),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 190, 18, 60), ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 190, 18, 60),
_ => ColorHelper.FromArgb(255, 26, 46, 71) _ => ColorHelper.FromArgb(255, 26, 46, 71)
}); });
@@ -189,6 +216,49 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore] [JsonIgnore]
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending; public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
[JsonIgnore]
public bool HasIssue => State is ScheduleQueueItemState.DataUnavailable or ScheduleQueueItemState.Error;
[JsonIgnore]
public Visibility IssueVisibility => HasIssue ? Visibility.Visible : Visibility.Collapsed;
[JsonIgnore]
public Visibility DataUnavailableActionVisibility =>
State == ScheduleQueueItemState.DataUnavailable ? Visibility.Visible : Visibility.Collapsed;
[JsonIgnore]
public string IssueTitle => State switch
{
ScheduleQueueItemState.DataUnavailable => "데이터 없음 - 자동 건너뜀",
ScheduleQueueItemState.Error => "송출 오류",
_ => string.Empty
};
[JsonIgnore]
public string IssueDetail => string.IsNullOrWhiteSpace(LastError)
? State == ScheduleQueueItemState.DataUnavailable
? "이전 실행에서 조건에 맞는 데이터가 없어 보류되었습니다. 다시 시작하면 재확인합니다."
: string.Empty
: LastError;
[JsonIgnore]
public string IssueOperatorHint => State == ScheduleQueueItemState.DataUnavailable
? "데이터가 들어온 뒤 다시 확인을 누르면 스케줄 대상에 복귀합니다."
: "CG 연결과 컷 파일, 데이터 수신 상태를 확인해 주세요.";
[JsonIgnore]
public string LastIssueLabel => LastIssueAt?.ToString("HH:mm:ss") ?? string.Empty;
[JsonIgnore]
public SolidColorBrush IssueBackgroundBrush => new(State == ScheduleQueueItemState.DataUnavailable
? ColorHelper.FromArgb(255, 69, 43, 14)
: ColorHelper.FromArgb(255, 60, 18, 31));
[JsonIgnore]
public SolidColorBrush IssueBorderBrush => new(State == ScheduleQueueItemState.DataUnavailable
? ColorHelper.FromArgb(255, 255, 184, 28)
: ColorHelper.FromArgb(255, 251, 113, 133));
[JsonIgnore] [JsonIgnore]
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(Channel, FormatName); public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(Channel, FormatName);
@@ -356,6 +426,18 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(DurationApplyStatusLabel)); OnPropertyChanged(nameof(DurationApplyStatusLabel));
} }
private void OnIssueStateChanged()
{
OnPropertyChanged(nameof(HasIssue));
OnPropertyChanged(nameof(IssueVisibility));
OnPropertyChanged(nameof(DataUnavailableActionVisibility));
OnPropertyChanged(nameof(IssueTitle));
OnPropertyChanged(nameof(IssueDetail));
OnPropertyChanged(nameof(IssueOperatorHint));
OnPropertyChanged(nameof(IssueBackgroundBrush));
OnPropertyChanged(nameof(IssueBorderBrush));
}
private void OnPreviewChanged() private void OnPreviewChanged()
{ {
OnPropertyChanged(nameof(PreviewSource)); OnPropertyChanged(nameof(PreviewSource));

View File

@@ -7,5 +7,6 @@ public enum ScheduleQueueItemState
Sending, Sending,
OnAir, OnAir,
Completed, Completed,
Error Error,
DataUnavailable
} }

View File

@@ -87,6 +87,7 @@ public sealed class ChannelScheduleEngine
} }
_lastPlaybackItemId = null; _lastPlaybackItemId = null;
ResetDataUnavailableItems();
_playbackCts = new CancellationTokenSource(); _playbackCts = new CancellationTokenSource();
IsRunning = true; IsRunning = true;
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -150,6 +151,7 @@ public sealed class ChannelScheduleEngine
try try
{ {
_lastPlaybackItemId = null; _lastPlaybackItemId = null;
ResetDataUnavailableItems();
ClearPreparedFrame(resetState: true); ClearPreparedFrame(resetState: true);
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -267,6 +269,23 @@ public sealed class ChannelScheduleEngine
RefreshQueueMarkers(); RefreshQueueMarkers();
} }
public bool RetryDataUnavailable(ChannelScheduleItem? item)
{
if (item is null || item.State != ScheduleQueueItemState.DataUnavailable)
{
return false;
}
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return true;
}
public async Task ForceNextAsync() public async Task ForceNextAsync()
{ {
await AdvanceToNextAsync().ConfigureAwait(false); await AdvanceToNextAsync().ConfigureAwait(false);
@@ -478,9 +497,9 @@ public sealed class ChannelScheduleEngine
if (previewFrame is null) if (previewFrame is null)
{ {
queueItem.State = ScheduleQueueItemState.Error; MarkDataUnavailable(
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다."; queueItem,
queueItem.CurrentRegionLabel = string.Empty; "현재 선택한 컷과 지역 조건에 맞는 데이터가 없어 준비할 수 없습니다.");
RefreshQueueMarkers(); RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}"); _logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}");
return; return;
@@ -520,16 +539,15 @@ public sealed class ChannelScheduleEngine
if (regionTargets.Count == 0) if (regionTargets.Count == 0)
{ {
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
queueItem.CurrentRegionLabel = string.Empty;
_lastPlaybackItemId = queueItem.Id; _lastPlaybackItemId = queueItem.Id;
MarkDataUnavailable(queueItem, "선택한 지역 조건에 송출 가능한 데이터가 없습니다.");
RefreshQueueMarkers(); RefreshQueueMarkers();
return; return;
} }
var playedAny = false; var playedAny = false;
var lastFailure = string.Empty; var lastFailure = string.Empty;
var dataUnavailableFailure = false;
if (ShouldUseAggregateScheduleSnapshot(template)) if (ShouldUseAggregateScheduleSnapshot(template))
{ {
@@ -559,6 +577,7 @@ public sealed class ChannelScheduleEngine
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError)) if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
{ {
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}"; lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}";
dataUnavailableFailure = true;
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}"); _logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}");
continue; continue;
} }
@@ -610,8 +629,21 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview(); queueItem.ClearInternalNextPreview();
_lastPlaybackItemId = queueItem.Id; _lastPlaybackItemId = queueItem.Id;
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error; if (playedAny)
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure); {
queueItem.State = ScheduleQueueItemState.Queued;
queueItem.LastError = string.Empty;
}
else if (dataUnavailableFailure)
{
MarkDataUnavailable(queueItem, string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
}
else
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure;
}
ClearSkipCurrentItem(queueItem); ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers(); RefreshQueueMarkers();
return; return;
@@ -642,6 +674,7 @@ public sealed class ChannelScheduleEngine
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out var validationError)) if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out var validationError))
{ {
lastFailure = $"{regionTarget.DisplayName}: {validationError}"; lastFailure = $"{regionTarget.DisplayName}: {validationError}";
dataUnavailableFailure = true;
_logService.Warning($"[{Channel}] 스케줄 지역 검증 실패: {lastFailure}"); _logService.Warning($"[{Channel}] 스케줄 지역 검증 실패: {lastFailure}");
continue; continue;
} }
@@ -695,8 +728,21 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview(); queueItem.ClearInternalNextPreview();
_lastPlaybackItemId = queueItem.Id; _lastPlaybackItemId = queueItem.Id;
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error; if (playedAny)
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure); {
queueItem.State = ScheduleQueueItemState.Queued;
queueItem.LastError = string.Empty;
}
else if (dataUnavailableFailure)
{
MarkDataUnavailable(queueItem, string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
}
else
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure;
}
ClearSkipCurrentItem(queueItem); ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers(); RefreshQueueMarkers();
} }
@@ -1624,7 +1670,7 @@ public sealed class ChannelScheduleEngine
foreach (var item in Queue) foreach (var item in Queue)
{ {
if (item == activeItem || item.State == ScheduleQueueItemState.Error) if (item == activeItem || item.State is ScheduleQueueItemState.Error or ScheduleQueueItemState.DataUnavailable)
{ {
continue; continue;
} }
@@ -1677,6 +1723,34 @@ public sealed class ChannelScheduleEngine
return item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next or ScheduleQueueItemState.Completed; return item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next or ScheduleQueueItemState.Completed;
} }
private void ResetDataUnavailableItems()
{
foreach (var item in Queue.Where(item => item.State == ScheduleQueueItemState.DataUnavailable))
{
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
}
}
private void MarkDataUnavailable(ChannelScheduleItem queueItem, string reason)
{
queueItem.LastError = NormalizeDataUnavailableReason(reason);
queueItem.State = ScheduleQueueItemState.DataUnavailable;
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearRenderedPreview();
queueItem.ClearInternalNextPreview();
}
private static string NormalizeDataUnavailableReason(string reason)
{
return string.IsNullOrWhiteSpace(reason)
? "현재 조건에 맞는 데이터가 없어 송출을 건너뜁니다."
: reason;
}
private sealed record PreparedCutFrame( private sealed record PreparedCutFrame(
ChannelScheduleItem Item, ChannelScheduleItem Item,
FormatTemplateDefinition Template, FormatTemplateDefinition Template,

View File

@@ -22,6 +22,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled); private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
private static readonly Brush PlaybackActiveIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 90, 84)); private static readonly Brush PlaybackActiveIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 90, 84));
private static readonly Brush PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216)); private static readonly Brush PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216));
private static readonly Brush DataIssueBackgroundBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 57, 38, 16));
private static readonly Brush DataIssueBorderBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 184, 28));
private readonly ChannelScheduleEngine _engine; private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter; private readonly ITornado3Adapter _adapter;
private readonly CutDebugStateStore _cutDebugStateStore; private readonly CutDebugStateStore _cutDebugStateStore;
@@ -94,6 +96,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync); ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat); AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
ResetQueueCommand = new RelayCommand(ResetQueue); ResetQueueCommand = new RelayCommand(ResetQueue);
RetryDataIssueCommand = new RelayCommand<ChannelScheduleItem>(RetryDataIssue, CanRetryDataIssue);
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem); RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp); MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown); MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
@@ -168,6 +171,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public RelayCommand ResetQueueCommand { get; } public RelayCommand ResetQueueCommand { get; }
public RelayCommand<ChannelScheduleItem> RetryDataIssueCommand { get; }
public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; } public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; }
public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; } public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; }
@@ -397,6 +402,27 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}"; public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
public Visibility ScheduleDataIssueVisibility =>
LatestScheduleDataIssueItem is null ? Visibility.Collapsed : Visibility.Visible;
public Brush ScheduleDataIssueBackgroundBrush => DataIssueBackgroundBrushValue;
public Brush ScheduleDataIssueBorderBrush => DataIssueBorderBrushValue;
public string ScheduleDataIssueTitle => LatestScheduleDataIssueItem is { } item
? $"{item.FormatName} 송출 보류"
: string.Empty;
public string ScheduleDataIssueMessage => LatestScheduleDataIssueItem?.IssueDetail ?? string.Empty;
public string ScheduleDataIssueDetail => LatestScheduleDataIssueItem is { } item
? $"{item.DisplayRegionLabel} / {item.LastIssueLabel}"
: string.Empty;
public string ScheduleDataIssueHint => LatestScheduleDataIssueItem is null
? string.Empty
: "시스템 오류가 아니라 현재 조건 데이터가 부족한 상태입니다. 데이터가 들어오면 항목의 다시 확인을 눌러 스케줄 대상에 복귀시킬 수 있습니다.";
public string SelectedFormatName => SelectedFormat?.Name ?? "컷을 선택하세요"; public string SelectedFormatName => SelectedFormat?.Name ?? "컷을 선택하세요";
public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다."; public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";
@@ -485,6 +511,12 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private ChannelScheduleItem? NextPlaybackItem => private ChannelScheduleItem? NextPlaybackItem =>
InternalNextPlaybackItem ?? QueueNextPlaybackItem; InternalNextPlaybackItem ?? QueueNextPlaybackItem;
private ChannelScheduleItem? LatestScheduleDataIssueItem =>
Queue
.Where(item => item.State == ScheduleQueueItemState.DataUnavailable)
.OrderByDescending(item => item.LastIssueAt ?? DateTimeOffset.MinValue)
.FirstOrDefault();
public async Task RefreshRegionOptionsAsync() public async Task RefreshRegionOptionsAsync()
{ {
await RebuildRegionOptionsAsync(); await RebuildRegionOptionsAsync();
@@ -749,6 +781,22 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다."); _logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
} }
private void RetryDataIssue(ChannelScheduleItem? item)
{
if (!_engine.RetryDataUnavailable(item))
{
return;
}
RefreshSummary();
_logService.Info($"[{Title}] 데이터 없음 항목을 다시 스케줄 대상으로 전환: {item?.DisplayName}");
}
private static bool CanRetryDataIssue(ChannelScheduleItem? item)
{
return item?.State == ScheduleQueueItemState.DataUnavailable;
}
private void RemoveItem(ChannelScheduleItem? item) private void RemoveItem(ChannelScheduleItem? item)
{ {
if (!_engine.Remove(item)) if (!_engine.Remove(item))
@@ -851,6 +899,13 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(QueuedItemCount), nameof(QueuedItemCount),
nameof(QueueFootnote), nameof(QueueFootnote),
nameof(QueueSummary), nameof(QueueSummary),
nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush),
nameof(ScheduleDataIssueTitle),
nameof(ScheduleDataIssueMessage),
nameof(ScheduleDataIssueDetail),
nameof(ScheduleDataIssueHint),
nameof(IsCgConnected), nameof(IsCgConnected),
nameof(CgStatusSummary), nameof(CgStatusSummary),
nameof(LoopSummary), nameof(LoopSummary),
@@ -1098,15 +1153,20 @@ public sealed class ChannelScheduleViewModel : ObservableObject
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel) or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName) or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
or nameof(ChannelScheduleItem.HasInternalNextPreview) or nameof(ChannelScheduleItem.HasInternalNextPreview)
or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt)
or nameof(ChannelScheduleItem.ThumbnailSource)) or nameof(ChannelScheduleItem.ThumbnailSource))
{ {
if (e.PropertyName is nameof(ChannelScheduleItem.State) if (e.PropertyName is nameof(ChannelScheduleItem.State)
or nameof(ChannelScheduleItem.DisplayName) or nameof(ChannelScheduleItem.DisplayName)
or nameof(ChannelScheduleItem.CurrentRegionLabel)) or nameof(ChannelScheduleItem.CurrentRegionLabel)
or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt))
{ {
NotifyPlaybackStateChanged(); NotifyPlaybackStateChanged();
} }
RetryDataIssueCommand.NotifyCanExecuteChanged();
NotifyPlaybackPreviewChanged(); NotifyPlaybackPreviewChanged();
} }
} }
@@ -1121,7 +1181,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextItemName), nameof(NextItemName),
nameof(QueuedItemCount), nameof(QueuedItemCount),
nameof(QueueFootnote), nameof(QueueFootnote),
nameof(QueueSummary)); nameof(QueueSummary),
nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush),
nameof(ScheduleDataIssueTitle),
nameof(ScheduleDataIssueMessage),
nameof(ScheduleDataIssueDetail),
nameof(ScheduleDataIssueHint));
} }
private void NotifyPlaybackPreviewChanged() private void NotifyPlaybackPreviewChanged()