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();
}
}
}