Add CMS waveform pre-processing panel with backfill endpoints

GET api/track/waveform-status and POST api/track/{id}/waveform (ApiKey);
CmsTrackService methods; TrackPreProcessing page with per-row and
sequential bulk generation; nav links from TrackList and Index.
This commit is contained in:
daniel-c-harvey
2026-06-05 17:56:25 -04:00
parent 1b493434d6
commit 6e25ad3085
8 changed files with 418 additions and 7 deletions
+15 -1
View File
@@ -7,5 +7,19 @@
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h3" GutterBottom="true">DeepDrft CMS</MudText>
<MudText Typo="Typo.body1">Administration panel — under construction.</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Administration panel.</MudText>
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.LibraryMusic"
Href="/tracks">
Tracks
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.GraphicEq"
Href="/tracks/preprocessing">
Waveform Pre-Processing
</MudButton>
</MudStack>
</MudContainer>
@@ -12,12 +12,20 @@
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudText Typo="Typo.h3">Tracks</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/new">
Add Track
</MudButton>
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.GraphicEq"
Href="/tracks/preprocessing">
Waveform Pre-Processing
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/new">
Add Track
</MudButton>
</MudStack>
</MudStack>
<MudTable T="TrackDto"
@@ -0,0 +1,91 @@
@page "/tracks/preprocessing"
@attribute [Authorize]
<PageTitle>Waveform Pre-Processing — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudStack Spacing="0">
<MudText Typo="Typo.h3">Waveform Pre-Processing</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">
Generate loudness profiles for tracks that predate the waveform seeker.
</MudText>
</MudStack>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || _missingCount == 0)"
OnClick="GenerateAllMissing">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Missing (@_missingCount)</span>
}
</MudButton>
</MudStack>
<MudTable T="WaveformStatusDto"
Items="_rows"
Loading="_loading"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true">
<NoRecordsContent>
<MudText Typo="Typo.body1">No tracks found.</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText Typo="Typo.body1">Loading waveform status…</MudText>
</LoadingContent>
<HeaderContent>
<MudTh>Track Name</MudTh>
<MudTh>Entry Key</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Entry Key">
<MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText>
</MudTd>
<MudTd DataLabel="Profile">
@if (context.HasProfile)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text"
Icon="@Icons.Material.Filled.CheckCircle">Stored</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Text"
Icon="@Icons.Material.Filled.Cancel">Missing</MudChip>
}
</MudTd>
<MudTd DataLabel="Actions">
@if (!context.HasProfile)
{
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@(_bulkRunning || IsGenerating(context.EntryKey))"
OnClick="@(() => GenerateOne(context))">
@if (IsGenerating(context.EntryKey))
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<span>Generate</span>
}
</MudButton>
}
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>
@@ -0,0 +1,135 @@
using DeepDrftManager.Services;
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftManager.Components.Pages.Tracks;
public partial class TrackPreProcessing : ComponentBase
{
private List<WaveformStatusDto> _rows = new();
private readonly HashSet<string> _generating = new();
private bool _loading = true;
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
private int _missingCount => _rows.Count(r => !r.HasProfile);
[Inject] private ICmsTrackService CmsTrackService { get; set; } = default!;
[Inject] private ISnackbar Snackbar { get; set; } = default!;
[Inject] private ILogger<TrackPreProcessing> Logger { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
await LoadStatus();
}
private async Task LoadStatus()
{
_loading = true;
var result = await CmsTrackService.GetWaveformStatusAsync();
_loading = false;
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
_rows = new List<WaveformStatusDto>();
return;
}
_rows = result.Value.OrderBy(r => r.HasProfile).ThenBy(r => r.TrackName).ToList();
}
private bool IsGenerating(string entryKey) => _generating.Contains(entryKey);
private async Task GenerateOne(WaveformStatusDto row)
{
if (!await GenerateForRow(row))
{
return;
}
Snackbar.Add($"Generated profile for '{row.TrackName}'.", Severity.Success);
}
private async Task GenerateAllMissing()
{
var missing = _rows.Where(r => !r.HasProfile).ToList();
if (missing.Count == 0)
{
return;
}
_bulkRunning = true;
_bulkTotal = missing.Count;
_bulkDone = 0;
var failures = 0;
// Sequential by design: one request at a time so a large backfill does not flood the API
// with concurrent WAV decodes.
foreach (var row in missing)
{
if (!await GenerateForRow(row))
{
failures++;
}
_bulkDone++;
StateHasChanged();
}
_bulkRunning = false;
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
}
else
{
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
/// <summary>
/// Runs generation for a single row, flipping its status on success. Returns false on failure
/// (a snackbar is raised here for the per-row path; the bulk path aggregates a summary). Marks
/// the row busy for the duration so its button shows a spinner and stays disabled.
/// </summary>
private async Task<bool> GenerateForRow(WaveformStatusDto row)
{
_generating.Add(row.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(row.EntryKey);
if (result.Success)
{
row.HasProfile = true;
return true;
}
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
if (!_bulkRunning)
{
Snackbar.Add($"Generate failed for '{row.TrackName}': {error}", Severity.Error);
}
return false;
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", row.EntryKey);
if (!_bulkRunning)
{
Snackbar.Add($"Generate failed for '{row.TrackName}' — please try again.", Severity.Error);
}
return false;
}
finally
{
_generating.Remove(row.EntryKey);
StateHasChanged();
}
}
}
@@ -281,4 +281,81 @@ public class CmsTrackService : ICmsTrackService
return Result.CreateFailResult("Failed to update track.");
}
}
public async Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/track/waveform-status", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for waveform status");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API waveform status failed: {Status}", (int)response.StatusCode);
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Failed to load waveform status.");
}
WaveformStatusDto[]? status;
try
{
status = await response.Content.ReadFromJsonAsync<WaveformStatusDto[]>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize waveform status from Content API response");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an unexpected response.");
}
if (status is null)
{
_logger.LogError("Content API returned a null waveform status list");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<WaveformStatusDto[]>.CreatePassResult(status);
}
}
public async Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for waveform generation of {EntryKey}", entryKey);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Track audio not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate waveform profile.");
}
}
}
@@ -55,4 +55,16 @@ public interface ICmsTrackService
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
CancellationToken ct = default);
/// <summary>
/// Fetch per-track waveform profile status from <c>GET api/track/waveform-status</c> for the
/// CMS PreProcessing panel. Unpaged — the admin catalogue is small.
/// </summary>
Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default);
/// <summary>
/// Trigger waveform profile generation for a single track via
/// <c>POST api/track/{entryKey}/waveform</c>. Maps a 404 to a "Track audio not found." failure.
/// </summary>
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
}