feat(waveform): generalize high-res compute to every track (Direction B)

Per-track high-res datum keyed by EntryKey in the renamed track-waveforms vault; computed at upload for all tracks, regenerable per-track via CMS, with a re-runnable backfill. Mix read path repointed so it keeps working.
This commit is contained in:
daniel-c-harvey
2026-06-17 10:18:44 -04:00
parent ad94354632
commit accf20ba57
16 changed files with 612 additions and 155 deletions
@@ -43,7 +43,8 @@
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
@@ -64,7 +65,7 @@
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
<MudTd DataLabel="Waveform">
<MudTd DataLabel="Profile">
@if (HasProfile(context.EntryKey))
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
@@ -74,6 +75,16 @@
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
}
</MudTd>
<MudTd DataLabel="High-res">
@if (HasHighRes(context.EntryKey))
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
}
</MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
@@ -100,7 +111,7 @@
</MudTooltip>
@if (!HasProfile(context.EntryKey))
{
<MudTooltip Text="Generate Waveform">
<MudTooltip Text="Generate profile">
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
Size="Size.Small"
Color="Color.Secondary"
@@ -108,6 +119,16 @@
OnClick="@(() => GenerateOneAsync(context))" />
</MudTooltip>
}
@if (!HasHighRes(context.EntryKey))
{
<MudTooltip Text="Generate high-res datum">
<MudIconButton Icon="@Icons.Material.Filled.Waves"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@(_bulkRunning || _generatingHighRes.Contains(context.EntryKey))"
OnClick="@(() => GenerateOneHighResAsync(context))" />
</MudTooltip>
}
</MudTd>
</RowTemplate>
<PagerContent>
@@ -127,7 +148,10 @@
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
private Dictionary<string, bool> _waveformStatus = new();
// EntryKey → HasHighRes (the per-track visualizer datum, phase-12 §5). Same lifecycle as above.
private Dictionary<string, bool> _highResStatus = new();
private readonly HashSet<string> _generating = new();
private readonly HashSet<string> _generatingHighRes = new();
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
private bool _bulkRunning;
@@ -140,6 +164,9 @@
private bool HasProfile(string entryKey) =>
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
private bool HasHighRes(string entryKey) =>
_highResStatus.TryGetValue(entryKey, out var hasHighRes) && hasHighRes;
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string imagePath) =>
$"/api/image/{Uri.EscapeDataString(imagePath)}";
@@ -147,16 +174,27 @@
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
/// <summary>Number of tracks missing the high-res visualizer datum — drives the parent's backfill button.</summary>
public int GetMissingHighResCount() => _highResStatus.Count(kv => !kv.Value);
/// <summary>
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
/// the per-row icons reflect the new state.
/// the per-row icons reflect the new state. One status fetch populates both the 512-bucket profile
/// map and the high-res datum map.
/// </summary>
public async Task RefreshWaveformStatusAsync()
{
var result = await CmsTrackService.GetWaveformStatusAsync();
_waveformStatus = result.Success && result.Value is not null
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
: new Dictionary<string, bool>();
if (result.Success && result.Value is not null)
{
_waveformStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
}
else
{
_waveformStatus = new Dictionary<string, bool>();
_highResStatus = new Dictionary<string, bool>();
}
StateHasChanged();
await OnStatusLoaded.InvokeAsync();
@@ -255,4 +293,34 @@
StateHasChanged();
}
}
private async Task GenerateOneHighResAsync(TrackDto track)
{
_generatingHighRes.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
if (result.Success)
{
_highResStatus[track.EntryKey] = true;
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"High-res generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"High-res generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generatingHighRes.Remove(track.EntryKey);
StateHasChanged();
}
}
}
@@ -19,21 +19,38 @@
@if (VM.Mode == BrowseMode.Tracks)
{
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
}
</MudButton>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Profiles (@(_grid?.GetMissingCount() ?? 0))</span>
}
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Waves"
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingHighResCount() ?? 0) == 0)"
OnClick="GenerateAllMissingHighResAsync">
@if (_highResBulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
}
else
{
<span>Backfill High-res (@(_grid?.GetMissingHighResCount() ?? 0))</span>
}
</MudButton>
</MudStack>
}
</MudStack>
@@ -147,11 +164,17 @@
StateHasChanged();
}
// Local state for the parent-owned "Generate All Missing" bulk run.
// Local state for the parent-owned "Generate All Profiles" bulk run.
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
// Local state for the parent-owned "Backfill High-res" bulk run (phase-12 §8a-new). Independent of
// the profile bulk above; both disable the grid's per-row buttons while either runs.
private bool _highResBulkRunning;
private int _highResBulkTotal;
private int _highResBulkDone;
protected override async Task OnInitializedAsync()
{
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
@@ -248,4 +271,71 @@
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
/// <summary>
/// Backfill the per-track high-res visualizer datum (phase-12 §5) for every track missing one, one
/// request at a time so a large backfill does not flood the API with concurrent WAV decodes. This is
/// the §8a-new backfill mechanism over the generalized track generate action — re-runnable (a second
/// run re-reads status and only retries what is still missing). On completion, refreshes the grid's
/// status maps so the per-row icons reflect the new state.
/// </summary>
private async Task GenerateAllMissingHighResAsync()
{
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
if (!statusResult.Success || statusResult.Value is null)
{
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
return;
}
var missing = statusResult.Value.Where(s => !s.HasHighRes).ToList();
if (missing.Count == 0)
{
return;
}
_highResBulkRunning = true;
_highResBulkTotal = missing.Count;
_highResBulkDone = 0;
_grid?.SetBulkRunning(true);
var failures = 0;
foreach (var status in missing)
{
try
{
var result = await CmsTrackService.GenerateHighResWaveformAsync(status.EntryKey);
if (!result.Success)
{
failures++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", status.EntryKey);
failures++;
}
_highResBulkDone++;
StateHasChanged();
}
_highResBulkRunning = false;
_grid?.SetBulkRunning(false);
if (_grid is not null)
{
await _grid.RefreshWaveformStatusAsync();
}
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Backfilled {succeeded} high-res datum(s).", Severity.Success);
}
else
{
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
}
}
}
@@ -501,6 +501,39 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform/high-res", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for high-res 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 high-res waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate high-res waveform datum.");
}
}
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -105,6 +105,14 @@ public interface ICmsTrackService
/// </summary>
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
/// <summary>
/// Trigger high-res visualizer datum generation for a single track via
/// <c>POST api/track/{entryKey}/waveform/high-res</c> (phase-12 §5). Re-runnable — recomputes on each
/// call. Drives the per-row generate action and the batch backfill. Maps a 404 to a "Track audio not
/// found." failure.
/// </summary>
Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default);
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);