Files
MBN_STOCK_WEBVIEW/MainWindow.NamedPlaylists.cs

946 lines
32 KiB
C#

using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private const string NamedPlaylistCorrelationQuarantineMessage =
"A named-playlist write result could not be confirmed. Restart the app before another named-playlist write.";
private async Task HandleNamedPlaylistListRequestAsync(JsonElement payload)
{
var requestId = GetString(payload, "requestId") ?? string.Empty;
if (!HasOnlyProperties(payload, "requestId", "maximumResults") ||
!TryGetSafeToken(
payload,
"requestId",
MaximumPlayoutRequestIdLength,
out requestId))
{
PostNamedPlaylistInvalidRequest(requestId, "list");
return;
}
var maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions;
if (payload.TryGetProperty("maximumResults", out var maximumElement) &&
(maximumElement.ValueKind != JsonValueKind.Number ||
!maximumElement.TryGetInt32(out maximumResults) ||
maximumResults is < 1 or > LegacyNamedPlaylistPersistenceService.MaximumDefinitions))
{
PostNamedPlaylistInvalidRequest(requestId, "list");
return;
}
var service = _namedPlaylistPersistenceService;
if (service is null)
{
PostNamedPlaylistUnavailable(requestId, "list");
return;
}
await ExecuteNamedPlaylistReadAsync(
requestId,
"list",
async cancellationToken =>
{
var result = await service.ListAsync(maximumResults, cancellationToken);
var suggestedProgramCode = await service.SuggestNextProgramCodeAsync(
cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
PostMessage("named-playlist-list-results", new
{
requestId,
result.RetrievedAt,
totalRowCount = result.Items.Count,
truncated = result.IsTruncated,
suggestedProgramCode,
canMutate = service.CanMutate && !IsNamedPlaylistWriteQuarantined(),
writeQuarantined = IsNamedPlaylistWriteQuarantined(),
definitions = result.Items.Select(item => new
{
item.ProgramCode,
item.Title
})
});
});
}
private async Task HandleNamedPlaylistLoadRequestAsync(JsonElement payload)
{
var requestId = GetString(payload, "requestId") ?? string.Empty;
var programCode = GetString(payload, "programCode") ?? string.Empty;
if (!HasOnlyProperties(payload, "requestId", "programCode") ||
!TryGetSafeToken(
payload,
"requestId",
MaximumPlayoutRequestIdLength,
out requestId) ||
!TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode))
{
PostNamedPlaylistInvalidRequest(requestId, "load", programCode);
return;
}
var service = _namedPlaylistPersistenceService;
if (service is null)
{
PostNamedPlaylistUnavailable(requestId, "load", programCode);
return;
}
await ExecuteNamedPlaylistReadAsync(
requestId,
"load",
async cancellationToken =>
{
var result = await service.LoadAsync(programCode, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
PostMessage("named-playlist-load-results", new
{
requestId,
definition = new
{
result.Definition.ProgramCode,
result.Definition.Title
},
result.RetrievedAt,
totalRowCount = result.Items.Count,
canMutate = service.CanMutate && !IsNamedPlaylistWriteQuarantined(),
writeQuarantined = IsNamedPlaylistWriteQuarantined(),
rows = result.Items.Select(item => new
{
item.ItemIndex,
enabled = item.IsEnabled,
item.Selection.GroupCode,
item.Selection.Subject,
item.Selection.GraphicType,
item.Selection.Subtype,
pageText = item.Page?.ToString() ?? string.Empty,
item.Selection.DataCode
})
});
await PostNamedComparisonRestoreAsync(
requestId,
result.Items,
cancellationToken);
await PostNamedForeignStockRestoreAsync(
requestId,
result.Items,
cancellationToken);
await PostNamedForeignIndexCandleRestoreAsync(
requestId,
result.Items,
cancellationToken);
await PostNamedKrxThemeRestoreAsync(
requestId,
result.Items,
cancellationToken);
// Keep all named restore batches correlated to this load. NXT
// theme verification runs last because it uses MariaDB after
// the Oracle-backed identity batches and performs a bounded
// item preview per title.
await PostNamedNxtThemeRestoreAsync(
requestId,
result.Items,
cancellationToken);
});
}
private async Task HandleNamedPlaylistCreateRequestAsync(JsonElement payload)
{
var requestId = GetString(payload, "requestId") ?? string.Empty;
var programCode = GetString(payload, "programCode") ?? string.Empty;
if (!HasOnlyProperties(payload, "requestId", "programCode", "title") ||
!TryGetSafeToken(
payload,
"requestId",
MaximumPlayoutRequestIdLength,
out requestId) ||
!TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode) ||
!TryGetNamedPlaylistText(
payload,
"title",
maximumLength: 128,
allowEmpty: false,
allowCaret: true,
out var title))
{
PostNamedPlaylistInvalidRequest(requestId, "create", programCode);
return;
}
await ExecuteNamedPlaylistWriteAsync(
requestId,
"create",
programCode,
(service, cancellationToken) => service.CreateDefinitionAsync(
programCode,
title,
cancellationToken));
}
private async Task HandleNamedPlaylistReplaceRequestAsync(JsonElement payload)
{
var requestId = GetString(payload, "requestId") ?? string.Empty;
var programCode = GetString(payload, "programCode") ?? string.Empty;
if (!HasOnlyProperties(payload, "requestId", "programCode", "items") ||
!TryGetSafeToken(
payload,
"requestId",
MaximumPlayoutRequestIdLength,
out requestId) ||
!TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode) ||
!TryParseNamedPlaylistItems(payload, out var items))
{
PostNamedPlaylistInvalidRequest(requestId, "replace", programCode);
return;
}
await ExecuteNamedPlaylistWriteAsync(
requestId,
"replace",
programCode,
(service, cancellationToken) => service.ReplaceItemsAsync(
programCode,
items,
cancellationToken));
}
private async Task HandleNamedPlaylistDeleteRequestAsync(JsonElement payload)
{
var requestId = GetString(payload, "requestId") ?? string.Empty;
var programCode = GetString(payload, "programCode") ?? string.Empty;
if (!HasOnlyProperties(payload, "requestId", "programCode") ||
!TryGetSafeToken(
payload,
"requestId",
MaximumPlayoutRequestIdLength,
out requestId) ||
!TryGetNamedPlaylistProgramCode(payload, "programCode", out programCode))
{
PostNamedPlaylistInvalidRequest(requestId, "delete", programCode);
return;
}
await ExecuteNamedPlaylistWriteAsync(
requestId,
"delete",
programCode,
(service, cancellationToken) => service.DeleteAsync(
programCode,
cancellationToken));
}
private async Task ExecuteNamedPlaylistReadAsync(
string requestId,
string operation,
Func<CancellationToken, Task> operationBody)
{
var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
var requestToken = requestCancellation.Token;
var browserGeneration = Volatile.Read(ref _namedPlaylistBrowserGeneration);
var previousCancellation = Interlocked.Exchange(
ref _namedPlaylistReadCancellation,
requestCancellation);
CancelRequest(previousCancellation);
var databaseActivityEntered = false;
try
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
requestCancellation.Cancel();
PostNamedPlaylistReadCancellationIfCurrent(
requestId,
operation,
requestCancellation);
return;
}
await _databaseActivityGate.WaitAsync(requestToken);
databaseActivityEntered = true;
requestToken.ThrowIfCancellationRequested();
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
requestCancellation.Cancel();
PostNamedPlaylistReadCancellationIfCurrent(
requestId,
operation,
requestCancellation);
return;
}
if (browserGeneration != Volatile.Read(ref _namedPlaylistBrowserGeneration))
{
requestCancellation.Cancel();
return;
}
await operationBody(requestToken);
requestToken.ThrowIfCancellationRequested();
}
catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
{
PostNamedPlaylistReadCancellationIfCurrent(
requestId,
operation,
requestCancellation);
}
catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
{
PostNamedPlaylistReadCancellationIfCurrent(
requestId,
operation,
requestCancellation);
}
catch (Exception) when (requestToken.IsCancellationRequested)
{
PostNamedPlaylistReadCancellationIfCurrent(
requestId,
operation,
requestCancellation);
}
catch (NamedPlaylistNotFoundException)
{
PostNamedPlaylistError(
requestId,
operation,
"NOT_FOUND",
"The named playlist does not exist.",
retryable: false,
outcomeUnknown: false);
}
catch (NamedPlaylistDataException)
{
PostNamedPlaylistError(
requestId,
operation,
"INVALID_DATA",
"The stored named-playlist data is invalid.",
retryable: false,
outcomeUnknown: false);
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch (ArgumentException)
{
PostNamedPlaylistInvalidRequest(requestId, operation);
}
catch (DatabaseOperationException exception)
{
PostNamedPlaylistError(
requestId,
operation,
"DATABASE_ERROR",
exception.Message,
retryable: true,
outcomeUnknown: false);
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch
{
PostNamedPlaylistError(
requestId,
operation,
"DATABASE_ERROR",
"The named-playlist database request failed.",
retryable: true,
outcomeUnknown: false);
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
finally
{
if (databaseActivityEntered)
{
_databaseActivityGate.Release();
}
Interlocked.CompareExchange(
ref _namedPlaylistReadCancellation,
null,
requestCancellation);
requestCancellation.Dispose();
}
}
private async Task ExecuteNamedPlaylistWriteAsync(
string requestId,
string operation,
string programCode,
Func<INamedPlaylistPersistenceService, CancellationToken, Task> operationBody)
{
var service = _namedPlaylistPersistenceService;
if (service is null || !service.CanMutate)
{
PostNamedPlaylistUnavailable(requestId, operation, programCode);
return;
}
if (IsNamedPlaylistWriteQuarantined())
{
PostNamedPlaylistError(
requestId,
operation,
"OUTCOME_UNKNOWN",
Volatile.Read(ref _namedPlaylistWriteQuarantineMessage) ??
NamedPlaylistCorrelationQuarantineMessage,
retryable: false,
outcomeUnknown: true,
programCode);
return;
}
if (!await _namedPlaylistWriteGate.WaitAsync(0))
{
PostNamedPlaylistError(
requestId,
operation,
"WRITE_BUSY",
"Another named-playlist write is already running.",
retryable: false,
outcomeUnknown: false,
programCode);
return;
}
var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
var requestToken = requestCancellation.Token;
var browserGeneration = Volatile.Read(ref _namedPlaylistBrowserGeneration);
Volatile.Write(ref _namedPlaylistWriteCancellation, requestCancellation);
var databaseActivityEntered = false;
var mutationStarted = false;
try
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
requestCancellation.Cancel();
PostNamedPlaylistWriteCancellationIfCurrent(
requestId,
operation,
programCode,
requestCancellation);
return;
}
await _databaseActivityGate.WaitAsync(requestToken);
databaseActivityEntered = true;
requestToken.ThrowIfCancellationRequested();
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
requestCancellation.Cancel();
PostNamedPlaylistWriteCancellationIfCurrent(
requestId,
operation,
programCode,
requestCancellation);
return;
}
if (IsNamedPlaylistWriteQuarantined())
{
PostNamedPlaylistError(
requestId,
operation,
"OUTCOME_UNKNOWN",
Volatile.Read(ref _namedPlaylistWriteQuarantineMessage) ??
NamedPlaylistCorrelationQuarantineMessage,
retryable: false,
outcomeUnknown: true,
programCode);
return;
}
mutationStarted = true;
Interlocked.Exchange(ref _namedPlaylistWriteInFlight, 1);
if (browserGeneration != Volatile.Read(ref _namedPlaylistBrowserGeneration))
{
mutationStarted = false;
requestCancellation.Cancel();
return;
}
await operationBody(service, requestToken);
if (browserGeneration != Volatile.Read(ref _namedPlaylistBrowserGeneration) ||
!ReferenceEquals(
Volatile.Read(ref _namedPlaylistWriteCancellation),
requestCancellation))
{
LatchNamedPlaylistWriteQuarantine(
NamedPlaylistCorrelationQuarantineMessage);
return;
}
PostMessage("named-playlist-mutation-results", new
{
requestId,
operation,
programCode,
completedAt = DateTimeOffset.UtcNow,
writeQuarantined = IsNamedPlaylistWriteQuarantined()
});
}
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
{
// App shutdown owns the cancellation and no Web document can receive it.
}
catch (NamedPlaylistMutationException exception)
{
if (exception.OutcomeUnknown)
{
LatchNamedPlaylistWriteQuarantine(exception.Message);
PostNamedPlaylistWriteErrorIfCurrent(
requestId,
operation,
programCode,
"OUTCOME_UNKNOWN",
NamedPlaylistCorrelationQuarantineMessage,
outcomeUnknown: true,
requestCancellation);
}
else if (requestToken.IsCancellationRequested)
{
PostNamedPlaylistWriteCancellationIfCurrent(
requestId,
operation,
programCode,
requestCancellation);
}
else
{
PostNamedPlaylistWriteErrorIfCurrent(
requestId,
operation,
programCode,
"WRITE_FAILED",
"The named-playlist write rolled back and was not retried.",
outcomeUnknown: false,
requestCancellation);
}
}
catch (NamedPlaylistNotFoundException)
{
PostNamedPlaylistWriteErrorIfCurrent(
requestId,
operation,
programCode,
"NOT_FOUND",
"The named playlist does not exist.",
outcomeUnknown: false,
requestCancellation);
}
catch (ArgumentException)
{
PostNamedPlaylistWriteErrorIfCurrent(
requestId,
operation,
programCode,
"INVALID_REQUEST",
"The named-playlist write request is invalid.",
outcomeUnknown: false,
requestCancellation);
}
catch (OperationCanceledException)
{
if (mutationStarted)
{
LatchNamedPlaylistWriteQuarantine(
NamedPlaylistCorrelationQuarantineMessage);
PostNamedPlaylistWriteErrorIfCurrent(
requestId,
operation,
programCode,
"OUTCOME_UNKNOWN",
NamedPlaylistCorrelationQuarantineMessage,
outcomeUnknown: true,
requestCancellation);
}
else
{
PostNamedPlaylistWriteCancellationIfCurrent(
requestId,
operation,
programCode,
requestCancellation);
}
}
catch
{
if (mutationStarted)
{
LatchNamedPlaylistWriteQuarantine(
NamedPlaylistCorrelationQuarantineMessage);
}
PostNamedPlaylistWriteErrorIfCurrent(
requestId,
operation,
programCode,
mutationStarted ? "OUTCOME_UNKNOWN" : "WRITE_FAILED",
mutationStarted
? NamedPlaylistCorrelationQuarantineMessage
: "The named-playlist write failed before a transaction began.",
outcomeUnknown: mutationStarted,
requestCancellation);
}
finally
{
Interlocked.Exchange(ref _namedPlaylistWriteInFlight, 0);
if (databaseActivityEntered)
{
_databaseActivityGate.Release();
}
Interlocked.CompareExchange(
ref _namedPlaylistWriteCancellation,
null,
requestCancellation);
requestCancellation.Dispose();
_namedPlaylistWriteGate.Release();
}
}
private void PostNamedPlaylistReadCancellationIfCurrent(
string requestId,
string operation,
CancellationTokenSource requestCancellation)
{
if (_lifetimeCancellation.IsCancellationRequested ||
!ReferenceEquals(
Volatile.Read(ref _namedPlaylistReadCancellation),
requestCancellation))
{
return;
}
PostNamedPlaylistError(
requestId,
operation,
"PLAYOUT_PRIORITY",
"The named-playlist read was canceled because playout has database priority.",
retryable: true,
outcomeUnknown: false);
}
private void PostNamedPlaylistWriteCancellationIfCurrent(
string requestId,
string operation,
string programCode,
CancellationTokenSource requestCancellation)
{
if (_lifetimeCancellation.IsCancellationRequested ||
!ReferenceEquals(
Volatile.Read(ref _namedPlaylistWriteCancellation),
requestCancellation))
{
return;
}
PostNamedPlaylistError(
requestId,
operation,
"PLAYOUT_PRIORITY",
"The named-playlist write stopped before execution because playout has database priority.",
retryable: false,
outcomeUnknown: false,
programCode);
}
private void PostNamedPlaylistWriteErrorIfCurrent(
string requestId,
string operation,
string programCode,
string code,
string message,
bool outcomeUnknown,
CancellationTokenSource requestCancellation)
{
if (_lifetimeCancellation.IsCancellationRequested ||
!ReferenceEquals(
Volatile.Read(ref _namedPlaylistWriteCancellation),
requestCancellation))
{
return;
}
PostNamedPlaylistError(
requestId,
operation,
code,
message,
retryable: false,
outcomeUnknown,
programCode);
}
private void PostNamedPlaylistInvalidRequest(
string requestId,
string operation,
string programCode = "") =>
PostNamedPlaylistError(
requestId,
operation,
"INVALID_REQUEST",
"The named-playlist request is invalid.",
retryable: false,
outcomeUnknown: false,
programCode);
private void PostNamedPlaylistUnavailable(
string requestId,
string operation,
string programCode = "") =>
PostNamedPlaylistError(
requestId,
operation,
"DATABASE_UNAVAILABLE",
_databaseInitializationError ?? "The named-playlist database is unavailable.",
retryable: false,
outcomeUnknown: false,
programCode);
private void PostNamedPlaylistError(
string requestId,
string operation,
string code,
string message,
bool retryable,
bool outcomeUnknown,
string programCode = "")
{
PostMessage("named-playlist-error", new
{
requestId,
operation,
programCode,
code,
message,
retryable = retryable && !outcomeUnknown,
outcomeUnknown,
writeQuarantined = IsNamedPlaylistWriteQuarantined()
});
}
private bool IsNamedPlaylistWriteQuarantined() =>
Volatile.Read(ref _namedPlaylistWriteQuarantined) != 0 ||
IsOperatorMutationQuarantined();
private void LatchNamedPlaylistWriteQuarantine(string message)
{
LatchOperatorMutationQuarantine(message);
if (Interlocked.CompareExchange(ref _namedPlaylistWriteQuarantined, 1, 0) == 0)
{
Volatile.Write(
ref _namedPlaylistWriteQuarantineMessage,
string.IsNullOrWhiteSpace(message)
? NamedPlaylistCorrelationQuarantineMessage
: message);
}
}
private void InvalidateNamedPlaylistBrowserRequests()
{
Interlocked.Increment(ref _namedPlaylistBrowserGeneration);
if (Volatile.Read(ref _namedPlaylistWriteInFlight) != 0)
{
LatchNamedPlaylistWriteQuarantine(
NamedPlaylistCorrelationQuarantineMessage);
}
var readCancellation = Interlocked.Exchange(
ref _namedPlaylistReadCancellation,
null);
CancelRequest(readCancellation);
var writeCancellation = Interlocked.Exchange(
ref _namedPlaylistWriteCancellation,
null);
CancelRequest(writeCancellation);
}
private static bool TryParseNamedPlaylistItems(
JsonElement payload,
out IReadOnlyList<NamedPlaylistStoredItem> items)
{
items = Array.Empty<NamedPlaylistStoredItem>();
if (!payload.TryGetProperty("items", out var itemsElement) ||
itemsElement.ValueKind != JsonValueKind.Array ||
itemsElement.GetArrayLength() > LegacyNamedPlaylistPersistenceService.MaximumItems)
{
return false;
}
var parsed = new List<NamedPlaylistStoredItem>(itemsElement.GetArrayLength());
var expectedIndex = 0;
foreach (var itemElement in itemsElement.EnumerateArray())
{
if (itemElement.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(
itemElement,
"itemIndex",
"enabled",
"groupCode",
"subject",
"graphicType",
"subtype",
"pageText",
"dataCode") ||
!itemElement.TryGetProperty("itemIndex", out var indexElement) ||
indexElement.ValueKind != JsonValueKind.Number ||
!indexElement.TryGetInt32(out var itemIndex) ||
itemIndex != expectedIndex ||
!itemElement.TryGetProperty("enabled", out var enabledElement) ||
enabledElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False) ||
!TryGetNamedPlaylistText(
itemElement,
"groupCode",
256,
allowEmpty: true,
allowCaret: false,
out var groupCode) ||
!TryGetNamedPlaylistText(
itemElement,
"subject",
4000,
allowEmpty: true,
allowCaret: false,
out var subject) ||
!TryGetNamedPlaylistText(
itemElement,
"graphicType",
256,
allowEmpty: true,
allowCaret: false,
out var graphicType) ||
!TryGetNamedPlaylistText(
itemElement,
"subtype",
256,
allowEmpty: true,
allowCaret: false,
out var subtype) ||
!TryGetNamedPlaylistText(
itemElement,
"pageText",
9,
allowEmpty: true,
allowCaret: false,
out var pageText) ||
!TryGetNamedPlaylistText(
itemElement,
"dataCode",
256,
allowEmpty: true,
allowCaret: false,
out var dataCode) ||
!TryParseNamedPlaylistPage(pageText, out var page))
{
return false;
}
parsed.Add(new NamedPlaylistStoredItem(
itemIndex,
enabledElement.GetBoolean(),
new LegacySceneSelection(
groupCode,
subject,
graphicType,
subtype,
dataCode),
page));
expectedIndex++;
}
items = parsed.AsReadOnly();
return true;
}
private static bool TryGetNamedPlaylistProgramCode(
JsonElement payload,
string propertyName,
out string value)
{
value = GetString(payload, propertyName) ?? string.Empty;
return value.Length == 8 && value.All(char.IsAsciiDigit);
}
private static bool TryGetNamedPlaylistText(
JsonElement payload,
string propertyName,
int maximumLength,
bool allowEmpty,
bool allowCaret,
out string value)
{
value = GetString(payload, propertyName) ?? string.Empty;
if (!payload.TryGetProperty(propertyName, out var element) ||
element.ValueKind != JsonValueKind.String ||
(!allowEmpty && value.Length == 0) ||
value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
(!allowCaret && value.Contains('^')))
{
return false;
}
return !value.Any(static character =>
char.IsControl(character) ||
char.IsSurrogate(character) ||
character == '\uFFFD');
}
private static bool TryParseNamedPlaylistPage(
string value,
out NamedPlaylistPageState? page)
{
page = null;
if (value.Length == 0)
{
return true;
}
var separator = value.IndexOf('/');
if (separator < 1 || separator != value.LastIndexOf('/') ||
!TryParseUnsignedPageNumber(value.AsSpan(0, separator), out var currentPage) ||
!TryParseUnsignedPageNumber(value.AsSpan(separator + 1), out var totalPages))
{
return false;
}
try
{
page = new NamedPlaylistPageState(currentPage, totalPages);
return string.Equals(page.ToString(), value, StringComparison.Ordinal);
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
private static bool TryParseUnsignedPageNumber(
ReadOnlySpan<char> value,
out int number)
{
number = 0;
if (value.Length is < 1 or > 4)
{
return false;
}
foreach (var character in value)
{
if (!char.IsAsciiDigit(character))
{
return false;
}
number = checked((number * 10) + (character - '0'));
}
return true;
}
}