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 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 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 items) { items = Array.Empty(); if (!payload.TryGetProperty("items", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Array || itemsElement.GetArrayLength() > LegacyNamedPlaylistPersistenceService.MaximumItems) { return false; } var parsed = new List(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 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; } }