Complete legacy operator UI and playout migration
This commit is contained in:
921
MainWindow.NamedPlaylists.cs
Normal file
921
MainWindow.NamedPlaylists.cs
Normal file
@@ -0,0 +1,921 @@
|
||||
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
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
256,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user