937 lines
32 KiB
C#
937 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 PostNamedForeignIndexCandleRestoreAsync(
|
|
requestId,
|
|
result.Items,
|
|
cancellationToken);
|
|
// Keep all named restore batches correlated to this load. NXT
|
|
// theme verification runs last because it can perform both an
|
|
// exact identity lookup and 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;
|
|
}
|
|
}
|