feature: CMS Opus status surfaces — backfill missing-N badge + upload Post-Processing phase (18.6)

This commit is contained in:
daniel-c-harvey
2026-06-23 14:06:21 -04:00
parent e5366bc4ec
commit 59f48bb8cb
9 changed files with 240 additions and 10 deletions
@@ -9,6 +9,7 @@
@inject ISnackbar Snackbar
@inject ILogger<Releases> Logger
@inject NavigationManager NavigationManager
@implements IDisposable
@attribute [Authorize]
<PageTitle>Releases — Deep DRFT Management</PageTitle>
@@ -51,11 +52,13 @@
<span>Backfill High-res (@MissingHighResCount)</span>
}
</MudButton>
@* 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. *@
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.GraphicEq"
@@ -68,7 +71,7 @@
}
else
{
<span>Backfill Opus</span>
<span>Backfill Opus (@MissingOpusCount)</span>
}
</MudButton>
</MudStack>
@@ -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<OpusStatusDto> _opusStatus = Array.Empty<OpusStatusDto>();
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<WaveformStatusDto>();
await RefreshOpusStatusAsync();
StateHasChanged();
}
/// <summary>
/// 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).
/// </summary>
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)
{