From 59f48bb8cb5c78457e8de75c2c815fad6becd12d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 14:06:21 -0400 Subject: [PATCH] =?UTF-8?q?feature:=20CMS=20Opus=20status=20surfaces=20?= =?UTF-8?q?=E2=80=94=20backfill=20missing-N=20badge=20+=20upload=20Post-Pr?= =?UTF-8?q?ocessing=20phase=20(18.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DeepDrftAPI/Controllers/TrackController.cs | 34 ++++++ .../Components/Pages/Tracks/BatchEdit.razor | 12 +- .../Components/Pages/Tracks/BatchRowModel.cs | 7 +- .../Pages/Tracks/BatchTrackList.razor | 2 + .../Components/Pages/Tracks/BatchUpload.razor | 17 ++- .../Components/Pages/Tracks/Releases.razor | 107 +++++++++++++++++- DeepDrftManager/Services/CmsTrackService.cs | 44 +++++++ DeepDrftManager/Services/ICmsTrackService.cs | 8 ++ DeepDrftModels/DTOs/OpusStatusDto.cs | 19 ++++ 9 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 DeepDrftModels/DTOs/OpusStatusDto.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index d8dfe0f..2340e8e 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -249,6 +249,40 @@ public class TrackController : ControllerBase return Ok(status); } + // GET api/track/opus-status ([ApiKeyAuthorize]) + // Admin Post-Processing view (18.6): returns every track with a flag for whether it carries a COMPLETE + // Opus artifact — both the Opus audio AND the seek/setup sidecar present (TrackFormatResolver.HasOpusAsync, + // the same completeness rule the 18.5 Backfill-Opus pass enqueues against; a half-derived track counts as + // missing). Mirrors GET waveform-status exactly: same ApiKey auth, same unpaged whole-catalogue shape, same + // literal-route placement before "{trackId}". The CMS reads it to show the Backfill-Opus "missing N" badge + // and to poll per-track Post-Processing status after an upload. + [ApiKeyAuthorize] + [HttpGet("opus-status")] + public async Task GetOpusStatus() + { + var tracks = await _sqlTrackService.GetAll(); + if (!tracks.Success || tracks.Value is null) + { + var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetOpusStatus failed to load tracks: {Error}", error); + return StatusCode(500, "Failed to load tracks"); + } + + var status = new List(tracks.Value.Count); + foreach (var track in tracks.Value) + { + status.Add(new OpusStatusDto + { + TrackId = track.Id, + EntryKey = track.EntryKey, + TrackName = track.TrackName, + HasOpus = await _formatResolver.HasOpusAsync(track.EntryKey), + }); + } + + return Ok(status); + } + // POST api/track/duration/backfill ([ApiKeyAuthorize], no body) // One-time admin backfill: for every track whose SQL duration is still null, read the duration from // the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index c0422dc..eebb7cc 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -518,7 +518,10 @@ { var row = _tracks[i]; - if (row.Status == BatchRowStatus.Done) + // Skip rows already processed in a prior submit attempt. PostProcessing counts as processed: + // the track persisted successfully (only its background Opus derive is still settling), so a + // re-submit after a partial failure must NOT re-upload it and mint a duplicate. + if (row.Status is BatchRowStatus.Done or BatchRowStatus.PostProcessing) { _processedCount++; continue; @@ -638,7 +641,12 @@ } } - row.Status = BatchRowStatus.Done; + // §3.1a: a new-track upload persists the track (live + lossless) and the server + // derives Opus in the background, so the row enters the visible Post-Processing + // phase — same as BatchUpload. (The metadata-only update path above stays Done: it + // changes no audio, so it triggers no transcode.) Non-blocking; the Releases view + // polls the durable Opus status to settle it. + row.Status = BatchRowStatus.PostProcessing; succeeded++; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs b/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs index 1d4ab85..93730ae 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs +++ b/DeepDrftManager/Components/Pages/Tracks/BatchRowModel.cs @@ -40,4 +40,9 @@ public class BatchRowModel : 0; } -public enum BatchRowStatus { Queued, Uploading, Done, Failed } +// Done is the terminal success state (track persisted + playable losslessly). PostProcessing is the +// visible third upload phase (§3.1a): the byte transfer and server persist are finished and the track is +// live, but the server-side background Opus transcode is still running. It is NOT a failure and never +// blocks completion — the form may navigate away while a row sits in PostProcessing; the Releases browse +// view polls the durable Opus status from there. +public enum BatchRowStatus { Queued, Uploading, PostProcessing, Done, Failed } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor b/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor index 48c1121..3750ceb 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor @@ -126,6 +126,8 @@ { BatchRowStatus.Uploading => @ Uploading, + BatchRowStatus.PostProcessing => @ + Post-Processing, BatchRowStatus.Done => @Done, BatchRowStatus.Failed => @Failed, _ => @Queued diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index 00b0bda..9ce1244 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -69,6 +69,15 @@ Value="@_tracks[0].UploadPercent" aria-label="Uploading track" /> } + else if (_tracks[0].Status == BatchRowStatus.PostProcessing) + { + @* §3.1a: track is live + plays lossless; the Opus transcode runs in the background. + Indeterminate (no client-side progress for a server-side job) and non-blocking. *@ + + + Post-Processing (deriving Opus)… + + } } @@ -519,7 +528,13 @@ } } - row.Status = BatchRowStatus.Done; + // §3.1a: the byte transfer + server persist are done and the track is live and plays + // losslessly — the upload is successful HERE. The server then derives Opus on a + // background worker, so the row enters the visible Post-Processing phase rather than + // jumping straight to Done. This never blocks: the loop continues and the form may + // navigate away while rows sit in Post-Processing; the Releases view polls the durable + // Opus status to settle each one. + row.Status = BatchRowStatus.PostProcessing; succeeded++; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/Releases.razor b/DeepDrftManager/Components/Pages/Tracks/Releases.razor index 6c9580e..66a125a 100644 --- a/DeepDrftManager/Components/Pages/Tracks/Releases.razor +++ b/DeepDrftManager/Components/Pages/Tracks/Releases.razor @@ -9,6 +9,7 @@ @inject ISnackbar Snackbar @inject ILogger Logger @inject NavigationManager NavigationManager +@implements IDisposable @attribute [Authorize] Releases — Deep DRFT Management @@ -51,11 +52,13 @@ Backfill High-res (@MissingHighResCount) } - @* Backfill-Opus (Phase 18.5). Unlike the two waveform buttons, the Opus derive runs on a - server-side background worker: the API decides which tracks lack Opus and enqueues them, so - there is no client-side "missing N" count to gate on and no per-track progress to render — the - action schedules the work and reports the (enqueued / skipped) outcome. Re-runnable: a second - press only enqueues tracks still missing Opus. Disabled while a press is in flight. *@ + @* Backfill-Opus (Phase 18.5 + 18.6 badge). The Opus derive runs on a server-side background + worker: pressing the button enqueues every track lacking a complete Opus artifact and reports + the (enqueued / skipped) outcome. The "missing N" badge (18.6) reads the same opus-status map + the page polls, giving visual parity with the two waveform backfill buttons — but unlike them, + the count is informational, not a per-track client loop (the work is scheduled, not driven from + here). The button stays pressable at N=0 (a no-op re-run is harmless); it only disables while a + press is in flight or another bulk run holds the page. *@ Backfill Opus + Backfill Opus (@MissingOpusCount) } @@ -160,6 +163,22 @@ private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile); private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes); + // EntryKey → HasOpus (a complete audio+sidecar derive). Loaded alongside the waveform status on init and + // re-read after a Backfill-Opus run so the "missing N" badge settles. Also the source the Post-Processing + // poll watches: a freshly uploaded track lands here with HasOpus=false and flips to true once the + // server-side background transcode finishes — the durable surface for the upload meter's Post-Processing + // phase after the form returns the admin to this view (§3.1a). + private IReadOnlyList _opusStatus = Array.Empty(); + + private int MissingOpusCount => _opusStatus.Count(s => !s.HasOpus); + + // Post-Processing poll: while any track is still missing Opus (a transcode in flight or not yet + // backfilled), re-read the opus-status map on an interval so the "missing N" badge and any per-track + // Post-Processing indicator settle without a manual refresh. Stops itself once nothing is missing, and is + // torn down on dispose. Non-blocking — it never gates an upload or a button; it only refreshes a count. + private const int OpusPollIntervalMs = 4000; + private CancellationTokenSource? _opusPollCts; + // Local state for the parent-owned "Generate All Profiles" bulk run. private bool _bulkRunning; private int _bulkTotal; @@ -195,9 +214,81 @@ _waveformStatus = result.Success && result.Value is not null ? result.Value : Array.Empty(); + + await RefreshOpusStatusAsync(); StateHasChanged(); } + /// + /// Re-read the per-track Opus derive status and (re)arm the Post-Processing poll when work is still + /// pending. Called on init, after a Backfill-Opus run, and from the poll itself. Best-effort: a failed + /// fetch leaves the previous map in place rather than zeroing the badge on a transient API blip. Does not + /// call StateHasChanged itself — callers batch it with their own render (the poll path renders explicitly). + /// + private async Task RefreshOpusStatusAsync() + { + var opusResult = await CmsTrackService.GetOpusStatusAsync(); + if (opusResult.Success && opusResult.Value is not null) + { + _opusStatus = opusResult.Value; + } + + if (MissingOpusCount > 0) + { + EnsureOpusPollRunning(); + } + } + + // Start the Post-Processing poll if it is not already running. The loop re-reads opus-status every + // OpusPollIntervalMs and renders; it exits as soon as nothing is missing (or on cancel/dispose). Guarded + // by a non-null CTS so overlapping callers (init + backfill) cannot start two loops. + private void EnsureOpusPollRunning() + { + if (_opusPollCts is not null) + { + return; + } + + _opusPollCts = new CancellationTokenSource(); + _ = PollOpusStatusAsync(_opusPollCts.Token); + } + + private async Task PollOpusStatusAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && MissingOpusCount > 0) + { + await Task.Delay(OpusPollIntervalMs, ct); + + var result = await CmsTrackService.GetOpusStatusAsync(); + if (result.Success && result.Value is not null) + { + _opusStatus = result.Value; + await InvokeAsync(StateHasChanged); + } + } + } + catch (OperationCanceledException) + { + // Expected on dispose / navigation away — nothing to do. + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Opus Post-Processing poll stopped on an unexpected error."); + } + finally + { + _opusPollCts?.Dispose(); + _opusPollCts = null; + } + } + + public void Dispose() + { + _opusPollCts?.Cancel(); + } + // Invalidates the cached per-track waveform status on all embedded grids so the next row expand // re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows // reflect the new waveform state on the next expand interaction. @@ -338,6 +429,10 @@ return; } + // Re-read the status map so the "missing N" badge reflects the just-enqueued work and the + // Post-Processing poll arms to watch the transcodes settle from N→0 as each finishes. + await RefreshOpusStatusAsync(); + var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped); if (enqueued == 0) { diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index d3c40e3..622ba31 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -804,6 +804,50 @@ public class CmsTrackService : ICmsTrackService } } + public async Task> GetOpusStatusAsync(CancellationToken ct = default) + { + var client = _httpClientFactory.CreateClient(ContentCmsClientName); + + HttpResponseMessage response; + try + { + response = await client.GetAsync("api/track/opus-status", ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Content API call failed for Opus status"); + return ResultContainer.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Content API Opus status failed: {Status}", (int)response.StatusCode); + return ResultContainer.CreateFailResult("Failed to load Opus status."); + } + + OpusStatusDto[]? status; + try + { + status = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize Opus status from Content API response"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + if (status is null) + { + _logger.LogError("Content API returned a null Opus status list"); + return ResultContainer.CreateFailResult("Content API returned an empty response."); + } + + return ResultContainer.CreatePassResult(status); + } + } + public async Task>> GetReleasesAsync(CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index f1452eb..57e1c36 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -161,6 +161,14 @@ public interface ICmsTrackService /// Task> BackfillOpusAsync(CancellationToken ct = default); + /// + /// Fetch per-track Opus derive status from GET api/track/opus-status (Phase 18.6) for the CMS + /// Post-Processing surfaces. Unpaged — the admin catalogue is small. Each row's HasOpus is true only + /// when the track carries a complete Opus artifact (audio + sidecar). Drives the Backfill-Opus "missing N" + /// badge and the post-upload Post-Processing poll. Idempotent read — safe to poll on an interval. + /// + Task> GetOpusStatusAsync(CancellationToken ct = default); + /// Returns all releases with track counts from GET api/track/albums. Task>> GetReleasesAsync(CancellationToken ct = default); diff --git a/DeepDrftModels/DTOs/OpusStatusDto.cs b/DeepDrftModels/DTOs/OpusStatusDto.cs new file mode 100644 index 0000000..a6edb40 --- /dev/null +++ b/DeepDrftModels/DTOs/OpusStatusDto.cs @@ -0,0 +1,19 @@ +namespace DeepDrftModels.DTOs; + +/// +/// Per-track Opus derive status for the CMS Post-Processing surfaces (Phase 18.6). Mirrors +/// : one row per track, flagging whether the track already carries a +/// complete Opus artifact. "Complete" means BOTH the Opus audio bytes AND the seek/setup +/// sidecar are present in the track-opus vault — a half-derived track (audio without sidecar) is +/// unseekable and counts as missing, so the Backfill-Opus pass re-derives it. is the +/// vault key the per-track enqueue trigger and the polling Post-Processing affordance key on. +/// +public class OpusStatusDto +{ + public long TrackId { get; set; } + public string EntryKey { get; set; } = string.Empty; + public string TrackName { get; set; } = string.Empty; + + /// True only when both the Opus audio and the seek/setup sidecar are stored (a complete derive). + public bool HasOpus { get; set; } +}