Merge p14-w1-releases-consolidation into dev (Phase 14: retire /tracks list, consolidate into /releases; catalogue cards → CUTS/SESSIONS/MIXES)
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
@page "/catalogue"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
@layout Layout.CmsLayout
|
||||
@inject NavigationManager Nav
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject ILogger<Index> Logger
|
||||
|
||||
<PageTitle>DeepDrft CMS</PageTitle>
|
||||
@@ -12,114 +13,91 @@
|
||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
|
||||
</MudItem>
|
||||
@foreach (var card in Cards)
|
||||
{
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard(card)
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private bool _tracksLoading = true;
|
||||
private bool _albumsLoading = true;
|
||||
private bool _genresLoading = true;
|
||||
// One card per release medium. Each deep-links to /releases with the medium tab pre-selected via the
|
||||
// same ?medium= convention the Add Track buttons use. The count is that medium's release total.
|
||||
private sealed record MediumCard(ReleaseMedium Medium, string Label, string Icon, Color Color);
|
||||
|
||||
private int? _trackCount;
|
||||
private int? _albumCount;
|
||||
private int? _genreCount;
|
||||
private static readonly IReadOnlyList<MediumCard> Cards = new[]
|
||||
{
|
||||
new MediumCard(ReleaseMedium.Cut, "CUTS", Icons.Material.Filled.Album, Color.Primary),
|
||||
new MediumCard(ReleaseMedium.Session, "SESSIONS", Icons.Material.Filled.Mic, Color.Secondary),
|
||||
new MediumCard(ReleaseMedium.Mix, "MIXES", Icons.Material.Filled.GraphicEq, Color.Tertiary),
|
||||
};
|
||||
|
||||
// Medium → release count (null while loading or on failure). Each medium's count is one cheap paged
|
||||
// read (pageSize 1) for its TotalCount, run concurrently.
|
||||
private readonly Dictionary<ReleaseMedium, int?> _counts = new();
|
||||
private readonly HashSet<ReleaseMedium> _loading = Cards.Select(c => c.Medium).ToHashSet();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
|
||||
// finally block so its card updates as soon as its own fetch returns.
|
||||
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
|
||||
// Each loader calls StateHasChanged in its finally block so its card updates as soon as its own
|
||||
// fetch returns, rather than blocking on the slowest of the three.
|
||||
await Task.WhenAll(Cards.Select(c => LoadCountAsync(c.Medium)));
|
||||
}
|
||||
|
||||
private async Task LoadTrackCount()
|
||||
private async Task LoadCountAsync(ReleaseMedium medium)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetTrackCountAsync();
|
||||
_trackCount = result.Success ? result.Value : null;
|
||||
// pageSize 1 — we only need TotalCount, not the rows. Sort column is required by the API but
|
||||
// immaterial to the count.
|
||||
var result = await CmsReleaseService.GetPagedAsync(
|
||||
medium, page: 1, pageSize: 1, sortColumn: "Title", sortDescending: false);
|
||||
_counts[medium] = result.Success && result.Value is not null ? result.Value.TotalCount : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard track count failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
Logger.LogWarning("Dashboard {Medium} count failed: {Error}",
|
||||
medium, result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tracksLoading = false;
|
||||
_loading.Remove(medium);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAlbumCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetReleasesAsync();
|
||||
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard album summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_albumsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadGenreCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetGenreSummariesAsync();
|
||||
_genreCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard genre summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_genresLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment SummaryCard(string label, string icon, Color color, bool loading, int? count) => __builder =>
|
||||
private RenderFragment SummaryCard(MediumCard card) => __builder =>
|
||||
{
|
||||
var loading = _loading.Contains(card.Medium);
|
||||
var count = _counts.GetValueOrDefault(card.Medium);
|
||||
<MudCard Elevation="8" Style="height: 100%;">
|
||||
<MudCardContent>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
|
||||
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
|
||||
<MudIcon Icon="@card.Icon" Color="@card.Color" Size="Size.Large" />
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
|
||||
<MudProgressCircular Color="@card.Color" Indeterminate="true" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
|
||||
<MudText Typo="Typo.h3" Color="@card.Color">@(count?.ToString() ?? "—")</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@card.Label</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
<MudCardActions Class="justify-center pb-4">
|
||||
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||
OnClick="@(() => Nav.NavigateTo("/tracks"))">
|
||||
<MudButton Variant="Variant.Text" Color="@card.Color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||
OnClick="@(() => Nav.NavigateTo(ReleasesHref(card.Medium)))">
|
||||
View
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
};
|
||||
|
||||
// Deep-link to the Releases page with this medium's tab pre-selected. Mirrors the ?medium= seed the
|
||||
// Add Track buttons use; the Releases page reads it to set the active tab.
|
||||
private static string ReleasesHref(ReleaseMedium medium) =>
|
||||
$"/releases?medium={medium.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -87,7 +86,7 @@
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
|
||||
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||
Disabled="_saving">
|
||||
Cancel
|
||||
</MudButton>
|
||||
@@ -564,15 +563,10 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Either branch changed catalogue data, so the browse caches are stale regardless of
|
||||
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
|
||||
// album and genre lists re-fetch.
|
||||
VM.Invalidate();
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
||||
Navigation.NavigateTo("/tracks/albums");
|
||||
Navigation.NavigateTo("/releases");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<BatchUpload> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
@@ -87,7 +86,7 @@
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
|
||||
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||
Disabled="_uploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
@@ -484,8 +483,7 @@
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
Navigation.NavigateTo("/releases");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -114,10 +114,69 @@ else
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
||||
<MudTh>Track Name</MudTh>
|
||||
@* Per-track waveform-datum status + generate (migrated from the retired
|
||||
CmsTrackGrid). The expanded child row is the releases view's only
|
||||
per-track surface, so the unique per-track Profile / High-res columns
|
||||
live here. *@
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
|
||||
@* Info column: per-track EntryKey + OriginalFileName tooltip (migrated
|
||||
from the retired CmsTrackGrid's .cms-track-info monospace block). *@
|
||||
<MudTh Style="width: 1%;"></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
||||
<MudTd DataLabel="Profile">
|
||||
@if (HasProfile(track.EntryKey))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="Generate profile">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@_generating.Contains(track.EntryKey)"
|
||||
OnClick="@(() => GenerateProfileAsync(track))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="High-res">
|
||||
@if (HasHighRes(track.EntryKey))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="Generate high-res datum">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Waves"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@_generatingHighRes.Contains(track.EntryKey)"
|
||||
OnClick="@(() => GenerateHighResAsync(track))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
@* Per-track info tooltip (restored from the retired CmsTrackGrid's
|
||||
.cms-track-info monospace block): EntryKey + OriginalFileName. *@
|
||||
<MudTd>
|
||||
<MudTooltip Placement="Placement.Left">
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.EntryKey</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(track.OriginalFileName))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.OriginalFileName</MudText>
|
||||
}
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Info"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
@@ -133,6 +192,26 @@ else
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires after any per-row waveform generate (profile or high-res) succeeds. The parent page
|
||||
/// wires this to its own <c>RefreshWaveformStatusAsync</c> so its missing-count badges stay
|
||||
/// current after an individual-row generate inside an expanded album row.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached per-track waveform status so the next row expand re-fetches fresh data
|
||||
/// from the API. Called by the parent page after a catalogue-wide bulk run so already-expanded
|
||||
/// rows reflect the new state on the next expand interaction.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync()
|
||||
{
|
||||
_profileStatus = null;
|
||||
_highResStatus = null;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Zero or more dedicated, header-labelled special-action columns (Session hero upload, Mix waveform
|
||||
// generate), each rendered as its own header cell + per-row cell between the Tracks and Actions
|
||||
// columns. The ALL and Cut tabs leave this empty and render exactly as before — only the standard
|
||||
@@ -181,6 +260,103 @@ else
|
||||
[ReleaseMedium.Mix] = "DJ Mix",
|
||||
};
|
||||
|
||||
// EntryKey → HasProfile / HasHighRes for the expanded-row per-track waveform columns (migrated from
|
||||
// the retired CmsTrackGrid). Loaded once per grid instance on first row expand; a per-row generate
|
||||
// flips a single entry to true. Null until first loaded.
|
||||
private Dictionary<string, bool>? _profileStatus;
|
||||
private Dictionary<string, bool>? _highResStatus;
|
||||
private readonly HashSet<string> _generating = new();
|
||||
private readonly HashSet<string> _generatingHighRes = new();
|
||||
|
||||
private bool HasProfile(string entryKey) =>
|
||||
_profileStatus is not null && _profileStatus.TryGetValue(entryKey, out var has) && has;
|
||||
|
||||
private bool HasHighRes(string entryKey) =>
|
||||
_highResStatus is not null && _highResStatus.TryGetValue(entryKey, out var has) && has;
|
||||
|
||||
// Fetch the catalogue-wide waveform status once and cache it. The admin catalogue is small (one unpaged
|
||||
// call covers it), and per-track status only matters for rows the admin actually expands.
|
||||
private async Task EnsureWaveformStatusAsync()
|
||||
{
|
||||
if (_profileStatus is not null) return;
|
||||
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
_profileStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
|
||||
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Leave both empty (not null) so we do not re-fetch on every expand after a transient failure;
|
||||
// the next OnReleasesChanged refresh path will rebuild the grid and retry.
|
||||
_profileStatus = new Dictionary<string, bool>();
|
||||
_highResStatus = new Dictionary<string, bool>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateProfileAsync(TrackDto track)
|
||||
{
|
||||
_generating.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
(_profileStatus ??= new())[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
|
||||
await OnWaveformGenerated.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateHighResAsync(TrackDto track)
|
||||
{
|
||||
_generatingHighRes.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
(_highResStatus ??= new())[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
|
||||
await OnWaveformGenerated.InvokeAsync();
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleExpand(AlbumRow row)
|
||||
{
|
||||
row.IsExpanded = !row.IsExpanded;
|
||||
@@ -189,6 +365,9 @@ else
|
||||
row.IsLoading = true;
|
||||
StateHasChanged();
|
||||
row.Tracks = await LoadTracksAsync(row.Release.Title);
|
||||
// The per-track Profile / High-res columns need waveform status for the rows just loaded.
|
||||
// Loaded once for the catalogue on first expand and cached for this grid instance.
|
||||
await EnsureWaveformStatusAsync();
|
||||
row.IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
own data load so a host (TrackList today, the 8.A tab strip later) renders it with no parameters and
|
||||
no VM plumbing. Re-loads on first render and re-fetches after a row mutation so the list stays in
|
||||
sync with the catalogue. *@
|
||||
<CmsAlbumBrowser Releases="_releases"
|
||||
<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="_releases"
|
||||
IsLoading="_loading"
|
||||
OnReleasesChanged="OnGridReleasesChanged" />
|
||||
OnReleasesChanged="OnGridReleasesChanged"
|
||||
OnWaveformGenerated="OnWaveformGenerated" />
|
||||
|
||||
@code {
|
||||
// Fires after a row mutation (delete) so a host can invalidate sibling caches derived from the same
|
||||
@@ -18,9 +20,23 @@
|
||||
// notification, not the data source. Optional: an embed that has no sibling state leaves it unset.
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
private IReadOnlyList<ReleaseDto> _releases = Array.Empty<ReleaseDto>();
|
||||
private bool _loading = true;
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override Task OnInitializedAsync() => ReloadAsync();
|
||||
|
||||
private async Task OnGridReleasesChanged()
|
||||
|
||||
@@ -6,17 +6,34 @@
|
||||
tab carries expand-tracks, delete, the Type chip, and per-row edit identically to the ALL tab — no
|
||||
forked grid. Cuts have no medium-specific action, so no SpecialColumns are supplied; the grid renders
|
||||
its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
|
||||
<CmsAlbumBrowser Releases="Releases"
|
||||
<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync" />
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated" />
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Cut;
|
||||
protected override string MediumNoun => "cuts";
|
||||
|
||||
protected override CutRow ToRow(ReleaseDto release) => new() { Release = release };
|
||||
protected override ReleaseDto ReleaseOf(CutRow row) => row.Release;
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
public sealed class CutRow
|
||||
{
|
||||
public required ReleaseDto Release { get; set; }
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
@using DeepDrftModels.DTOs
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (Genres.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
@foreach (var genre in Genres)
|
||||
{
|
||||
var isExpanded = ExpandedGenre == genre.Genre;
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="@(isExpanded ? 4 : 1)"
|
||||
Style="cursor: pointer;"
|
||||
@onclick="@(() => ToggleGenre(genre.Genre))">
|
||||
<div class="@SwatchClass(isExpanded)"></div>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6">@genre.Genre</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@if (ExpandedGenre is not null)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
|
||||
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public string? ExpandedGenre { get; set; }
|
||||
[Parameter] public EventCallback<string?> OnExpandedGenreChanged { get; set; }
|
||||
|
||||
// The view model owns the toggle (selecting the open genre collapses it), so we pass the raw
|
||||
// clicked genre rather than pre-computing the next state here — keeps the toggle logic single-sourced.
|
||||
private async Task ToggleGenre(string genre) =>
|
||||
await OnExpandedGenreChanged.InvokeAsync(genre);
|
||||
|
||||
private static string SwatchClass(bool isExpanded) =>
|
||||
isExpanded ? "cms-genre-swatch cms-genre-swatch--active" : "cms-genre-swatch";
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.cms-genre-swatch {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cms-genre-swatch--active {
|
||||
background-color: var(--mud-palette-primary-hover);
|
||||
}
|
||||
@@ -23,9 +23,9 @@ else
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks/archive"
|
||||
Href="/releases"
|
||||
Class="mb-4">
|
||||
Back to Release Archive
|
||||
Back to Releases
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
|
||||
@@ -41,9 +41,24 @@ else
|
||||
/// </summary>
|
||||
[Parameter] public bool Embedded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
|
||||
protected override string MediumNoun => "mixes";
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override MixRow ToRow(ReleaseDto release) => new()
|
||||
{
|
||||
Release = release,
|
||||
@@ -56,9 +71,11 @@ else
|
||||
// both branches above render the same markup without duplication. The Mix declares one dedicated
|
||||
// "Waveform" special-action column; the grid renders it between Tracks and Actions, handing the cell
|
||||
// each release, and RowFor recovers the matching MixRow's generate state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
|
||||
@@ -24,9 +24,9 @@ else
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks/archive"
|
||||
Href="/releases"
|
||||
Class="mb-4">
|
||||
Back to Release Archive
|
||||
Back to Releases
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
|
||||
@@ -42,9 +42,24 @@ else
|
||||
/// </summary>
|
||||
[Parameter] public bool Embedded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Session;
|
||||
protected override string MediumNoun => "sessions";
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override SessionRow ToRow(ReleaseDto release) => new()
|
||||
{
|
||||
Release = release,
|
||||
@@ -57,9 +72,11 @@ else
|
||||
// both branches above render the same markup without duplication. The Session declares one dedicated
|
||||
// "Hero" special-action column; the grid renders it between Tracks and Actions, handing the cell each
|
||||
// release, and RowFor recovers the matching SessionRow's upload state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsTrackGrid> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@if (ShowAddButton)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/tracks/upload">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudTable T="TrackDto"
|
||||
@ref="_table"
|
||||
ServerData="LoadServerData"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true"
|
||||
RowsPerPage="@PageSize"
|
||||
AllowUnsorted="false">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Track #</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Art</MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
||||
<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;">Profile</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track #">@context.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@if (!string.IsNullOrEmpty(context.Release?.ImagePath))
|
||||
{
|
||||
<div class="cms-track-thumb"
|
||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-track-thumb cms-track-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
|
||||
<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="Profile">
|
||||
@if (HasProfile(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="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"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}/edit")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
||||
</MudTooltip>
|
||||
<MudTooltip>
|
||||
<TooltipContent>
|
||||
<div class="cms-track-info">
|
||||
<div>Entry: @context.EntryKey</div>
|
||||
<div>File: @(context.OriginalFileName ?? "—")</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
@if (!HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Generate profile">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
|
||||
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>
|
||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? AlbumFilter { get; set; }
|
||||
[Parameter] public string? GenreFilter { get; set; }
|
||||
[Parameter] public bool ShowAddButton { get; set; } = true;
|
||||
[Parameter] public int PageSize { get; set; } = 20;
|
||||
[Parameter] public EventCallback OnTracksChanged { get; set; }
|
||||
[Parameter] public EventCallback OnStatusLoaded { get; set; }
|
||||
|
||||
private MudTable<TrackDto>? _table;
|
||||
|
||||
// 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;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
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)}";
|
||||
|
||||
/// <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. 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();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Set by the parent while its bulk generate runs so per-row buttons disable.</summary>
|
||||
public void SetBulkRunning(bool running)
|
||||
{
|
||||
_bulkRunning = running;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
||||
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
pageNumber, state.PageSize, sortColumn, sortDescending,
|
||||
AlbumFilter, GenreFilter, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
||||
}
|
||||
|
||||
var page = result.Value;
|
||||
return new TableData<TrackDto>
|
||||
{
|
||||
Items = page.Items,
|
||||
TotalItems = page.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDelete(TrackDto track)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete track",
|
||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
await OnTracksChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateOneAsync(TrackDto track)
|
||||
{
|
||||
_generating.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
_waveformStatus[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(track.EntryKey);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.cms-track-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-track-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.cms-track-info {
|
||||
font-family: monospace;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
@page "/releases"
|
||||
@page "/tracks"
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/archive"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<Releases> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Releases — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudText Typo="Typo.h3">Releases</MudText>
|
||||
|
||||
@* Catalogue-wide waveform backfill (migrated from the retired /tracks view). Both buttons act over
|
||||
every track's waveform status — independent of any single grid — so the page owns the status map
|
||||
directly: it computes the missing counts and re-fetches after a run. No grid reference involved. *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || MissingProfileCount == 0)"
|
||||
OnClick="GenerateAllMissingAsync">
|
||||
@if (_bulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate All Profiles (@MissingProfileCount)</span>
|
||||
}
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Waves"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || MissingHighResCount == 0)"
|
||||
OnClick="GenerateAllMissingHighResAsync">
|
||||
@if (_highResBulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Backfill High-res (@MissingHighResCount)</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@* Medium tab strip: an ALL tab plus one explicit MudTabPanel per ReleaseMedium, ALL left-most. Each
|
||||
panel is hand-declared in markup (not enum-driven) so @ref captures of the per-tab grid components
|
||||
are possible. Adding a future medium requires a hand-added MudTabPanel; its position in markup must
|
||||
match ReleaseMedium enum order, since the ?medium= deep-link seed and ActiveMedium getter are
|
||||
position-based (panel 0 = ALL, panels 1.. = enum values in order). *@
|
||||
@* Medium-aware Add Track: the button reflects the active tab and pre-selects the upload form to that
|
||||
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a seed
|
||||
only — the upload form's selector stays user-changeable after landing. *@
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="@AddTrackHref(ActiveMedium)">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
|
||||
@bind-ActivePanelIndex="_activeTabIndex">
|
||||
<MudTabPanel Text="ALL">
|
||||
<CmsAllReleasesGrid @ref="_allGrid"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Cut]">
|
||||
<CmsCutBrowser @ref="_cutBrowser"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Session]">
|
||||
<CmsSessionBrowser @ref="_sessionBrowser"
|
||||
Embedded="true"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Mix]">
|
||||
<CmsMixBrowser @ref="_mixBrowser"
|
||||
Embedded="true"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// Active tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in order. Seeded
|
||||
// from the ?medium= query param so the catalogue cards can deep-link straight to a medium's tab.
|
||||
private int _activeTabIndex;
|
||||
|
||||
// Optional deep-link target from the catalogue cards (?medium=session selects the Sessions tab) and the
|
||||
// seed for the Add Track button on the ALL tab. Read once on init; the user can switch tabs freely after.
|
||||
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
|
||||
|
||||
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut; each
|
||||
// medium tab maps to its enum value by position, so a fourth medium tab gets a correct Add Track for
|
||||
// free — no markup fork.
|
||||
private ReleaseMedium ActiveMedium =>
|
||||
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
|
||||
|
||||
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
|
||||
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
|
||||
private static string AddTrackHref(ReleaseMedium medium) =>
|
||||
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
|
||||
|
||||
// Medium → tab label. The one place medium display text lives for the tab strip. The ALL tab is
|
||||
// rendered separately (it is not a medium). Tabs are explicit markup so @ref captures work.
|
||||
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
|
||||
new Dictionary<ReleaseMedium, string>
|
||||
{
|
||||
[ReleaseMedium.Cut] = "CUTS",
|
||||
[ReleaseMedium.Session] = "SESSIONS",
|
||||
[ReleaseMedium.Mix] = "MIXES",
|
||||
};
|
||||
|
||||
// @ref handles for the per-tab grids. Used to (a) invalidate their cached per-track waveform status
|
||||
// after a page-level bulk run, and (b) to wire OnWaveformGenerated so per-row generates bubble up
|
||||
// and refresh the page-level missing-count badges. Tabs are now explicit markup rather than the
|
||||
// former enum-driven MediumGrid() switch so @ref captures are possible.
|
||||
private CmsAllReleasesGrid? _allGrid;
|
||||
private CmsCutBrowser? _cutBrowser;
|
||||
private CmsSessionBrowser? _sessionBrowser;
|
||||
private CmsMixBrowser? _mixBrowser;
|
||||
|
||||
// EntryKey → HasProfile / HasHighRes, loaded once on init so the bulk buttons can show accurate missing
|
||||
// counts without depending on any rendered grid. Re-fetched after each bulk run so the counts settle.
|
||||
private IReadOnlyList<WaveformStatusDto> _waveformStatus = Array.Empty<WaveformStatusDto>();
|
||||
|
||||
private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile);
|
||||
private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes);
|
||||
|
||||
// Local state for the parent-owned "Generate All Profiles" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
private int _bulkDone;
|
||||
|
||||
// Local state for the "Backfill High-res" bulk run. Independent of the profile bulk above.
|
||||
private bool _highResBulkRunning;
|
||||
private int _highResBulkTotal;
|
||||
private int _highResBulkDone;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Seed the active tab from ?medium= so a catalogue card deep-links straight to its medium. Panel 0
|
||||
// is ALL; a recognised medium maps to its 1-based position. Unrecognised/absent falls through to ALL.
|
||||
if (!string.IsNullOrWhiteSpace(MediumParam)
|
||||
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
||||
&& Enum.IsDefined(medium))
|
||||
{
|
||||
_activeTabIndex = Array.IndexOf(Enum.GetValues<ReleaseMedium>(), medium) + 1;
|
||||
}
|
||||
|
||||
await RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshWaveformStatusAsync()
|
||||
{
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformStatus = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<WaveformStatusDto>();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// 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.
|
||||
private async Task InvalidateAllGridsAsync()
|
||||
{
|
||||
var tasks = new[]
|
||||
{
|
||||
_allGrid?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_cutBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_sessionBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_mixBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
};
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill every track missing a waveform profile, one request at a time so a large backfill does not
|
||||
/// flood the API with concurrent WAV decodes. On completion, re-reads the status map so the missing
|
||||
/// count settles.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingAsync()
|
||||
{
|
||||
var missing = _waveformStatus.Where(s => !s.HasProfile).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bulkRunning = true;
|
||||
_bulkTotal = missing.Count;
|
||||
_bulkDone = 0;
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_bulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_bulkRunning = false;
|
||||
await RefreshWaveformStatusAsync();
|
||||
await InvalidateAllGridsAsync();
|
||||
|
||||
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>
|
||||
/// Backfill the per-track high-res visualizer datum for every track missing one, one request at a time
|
||||
/// so a large backfill does not flood the API with concurrent WAV decodes. Re-runnable (a second run
|
||||
/// re-reads status and only retries what is still missing). On completion, re-reads the status map.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingHighResAsync()
|
||||
{
|
||||
var missing = _waveformStatus.Where(s => !s.HasHighRes).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_highResBulkRunning = true;
|
||||
_highResBulkTotal = missing.Count;
|
||||
_highResBulkDone = 0;
|
||||
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;
|
||||
await RefreshWaveformStatusAsync();
|
||||
await InvalidateAllGridsAsync();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
@page "/tracks"
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/genres"
|
||||
@page "/tracks/archive"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackList> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
||||
|
||||
<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>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<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>
|
||||
|
||||
@* Top-level browse dimension. The former three-way toggle (Tracks / Releases / Release Archive)
|
||||
collapsed to two (§8.A): "Releases" now hosts the in-page medium tab strip below, subsuming both
|
||||
the old Releases grid (as the ALL tab) and the retired Release Archive cards. *@
|
||||
<MudToggleGroup T="BrowseMode"
|
||||
Value="VM.Mode"
|
||||
ValueChanged="OnModeChanged"
|
||||
SelectionMode="SelectionMode.SingleSelection"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
Class="mb-4">
|
||||
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
|
||||
</MudToggleGroup>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
|
||||
}
|
||||
else if (VM.Mode == BrowseMode.Albums)
|
||||
{
|
||||
@* The Release Archive tab strip (§8.A): an ALL tab plus one tab per ReleaseMedium, ALL left-most.
|
||||
The medium tabs are enum-driven — a fourth medium adds a tab automatically; only a label-lookup
|
||||
entry (MediumTabLabels) and a content arm (MediumGrid) are needed, no markup fork. Selecting a
|
||||
tab swaps the grid below in place; no navigation to a separate page occurs. *@
|
||||
@* Medium-aware Add Track (§8.E): the button lives in the tab-strip chrome (not inside any grid
|
||||
component — 8.C owns those) and reflects the active tab. It pre-selects the upload form to the
|
||||
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a
|
||||
seed only — the upload form's selector stays user-changeable after landing. *@
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="@AddTrackHref(ActiveMedium)">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
|
||||
@bind-ActivePanelIndex="_activeTabIndex">
|
||||
<MudTabPanel Text="ALL">
|
||||
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
|
||||
</MudTabPanel>
|
||||
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
||||
{
|
||||
<MudTabPanel Text="@MediumTabLabels[medium]">
|
||||
@MediumGrid(medium)
|
||||
</MudTabPanel>
|
||||
}
|
||||
</MudTabs>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Genre browse keeps its route (/tracks/genres) but lost its tab to Release Archive (§3.1).
|
||||
Reachable by direct URL; no longer in the toggle group. *@
|
||||
<CmsGenreBrowser Genres="VM.Genres"
|
||||
IsLoading="VM.GenresLoading"
|
||||
ExpandedGenre="@VM.ExpandedGenre"
|
||||
OnExpandedGenreChanged="OnExpandedGenreChanged" />
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private CmsTrackGrid? _grid;
|
||||
|
||||
// Active Release-Archive tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in
|
||||
// order. Drives the medium-aware Add Track button (§8.E).
|
||||
private int _activeTabIndex;
|
||||
|
||||
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut
|
||||
// (Daniel, 2026-06-13); each medium tab maps to its enum value by position, so a fourth medium tab
|
||||
// gets a correct Add Track for free — no markup fork.
|
||||
private ReleaseMedium ActiveMedium =>
|
||||
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
|
||||
|
||||
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
|
||||
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
|
||||
private static string AddTrackHref(ReleaseMedium medium) =>
|
||||
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
|
||||
|
||||
// Medium → tab label. The one place medium display text lives for the tab strip; a future medium adds
|
||||
// one entry here and surfaces a tab automatically. Mirrors the extension discipline the retired
|
||||
// ReleaseArchiveBrowser used for its cards. The ALL tab is rendered separately (it is not a medium).
|
||||
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
|
||||
new Dictionary<ReleaseMedium, string>
|
||||
{
|
||||
[ReleaseMedium.Cut] = "CUTS",
|
||||
[ReleaseMedium.Session] = "SESSIONS",
|
||||
[ReleaseMedium.Mix] = "MIXES",
|
||||
};
|
||||
|
||||
// Medium → embedded grid. Each medium's grid is its own component (Cut has no per-row action; Session
|
||||
// carries hero upload; Mix carries waveform generation), so the content dispatch is a per-medium
|
||||
// mapping by nature — but it is a single switch returning a fragment, not a markup fork. The browsers
|
||||
// render Embedded so their standalone page chrome (container, title, back button) is suppressed here.
|
||||
private RenderFragment MediumGrid(ReleaseMedium medium) => medium switch
|
||||
{
|
||||
ReleaseMedium.Cut => @<CmsCutBrowser />,
|
||||
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
|
||||
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
|
||||
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
|
||||
};
|
||||
|
||||
// The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
|
||||
// the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
|
||||
private void OnAlbumsChanged()
|
||||
{
|
||||
VM.Invalidate();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// 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
|
||||
// separate Archive mode is retired (§8.A) but the route stays reachable rather than 404ing.
|
||||
var uri = NavigationManager.Uri;
|
||||
var initial =
|
||||
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
|
||||
: BrowseMode.Tracks;
|
||||
await VM.SwitchModeAsync(initial);
|
||||
}
|
||||
|
||||
private async Task OnModeChanged(BrowseMode mode)
|
||||
{
|
||||
await VM.SwitchModeAsync(mode);
|
||||
var path = mode switch
|
||||
{
|
||||
BrowseMode.Albums => "/tracks/albums",
|
||||
BrowseMode.Genres => "/tracks/genres",
|
||||
_ => "/tracks"
|
||||
};
|
||||
NavigationManager.NavigateTo(path, replace: true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnExpandedGenreChanged(string? genre)
|
||||
{
|
||||
VM.SetExpandedGenre(genre);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill every track missing a waveform profile, one request at a time so a large backfill
|
||||
/// does not flood the API with concurrent WAV decodes. On completion, refreshes the grid's
|
||||
/// status map so the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingAsync()
|
||||
{
|
||||
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.HasProfile).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bulkRunning = true;
|
||||
_bulkTotal = missing.Count;
|
||||
_bulkDone = 0;
|
||||
_grid?.SetBulkRunning(true);
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_bulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_bulkRunning = false;
|
||||
_grid?.SetBulkRunning(false);
|
||||
|
||||
if (_grid is not null)
|
||||
{
|
||||
await _grid.RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
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>
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
||||
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
|
||||
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
|
||||
|
||||
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
|
||||
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
|
||||
|
||||
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
|
||||
// /account/login + /account/logout Razor pages that ship in the AuthBlocksWeb RCL.
|
||||
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>The browse dimensions for the /tracks page.</summary>
|
||||
public enum BrowseMode
|
||||
{
|
||||
Tracks,
|
||||
|
||||
/// <summary>The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A).</summary>
|
||||
Albums,
|
||||
|
||||
Genres,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the /tracks browser's current mode plus the album- and genre-mode datasets. Scoped per
|
||||
/// circuit. Album and genre lists are fetched lazily on first switch into their mode and cached for
|
||||
/// the circuit's lifetime; Track mode owns its own paging inside <c>CmsTrackGrid</c> and needs no
|
||||
/// state here.
|
||||
/// </summary>
|
||||
public class CmsTrackBrowserViewModel
|
||||
{
|
||||
private readonly ICmsTrackService _trackService;
|
||||
|
||||
public CmsTrackBrowserViewModel(ICmsTrackService trackService)
|
||||
{
|
||||
_trackService = trackService;
|
||||
}
|
||||
|
||||
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
|
||||
|
||||
// Genre mode.
|
||||
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
|
||||
public bool GenresLoading { get; private set; }
|
||||
public string? ExpandedGenre { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Switch the active mode, lazily loading the genre dataset on first entry into Genre mode and
|
||||
/// collapsing any expanded genre row. Track mode and the all-releases grid (Albums mode) each own
|
||||
/// their own data — the grid loads itself (see <c>CmsAllReleasesGrid</c>) — so no fetch happens for
|
||||
/// either here.
|
||||
/// </summary>
|
||||
public async Task SwitchModeAsync(BrowseMode mode)
|
||||
{
|
||||
Mode = mode;
|
||||
ExpandedGenre = null; // collapse on mode switch
|
||||
|
||||
if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
|
||||
{
|
||||
GenresLoading = true;
|
||||
var result = await _trackService.GetGenreSummariesAsync();
|
||||
Genres = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<GenreSummaryDto>();
|
||||
GenresLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Toggle the expanded genre row. Selecting the already-expanded genre collapses it.</summary>
|
||||
public void SetExpandedGenre(string? genre)
|
||||
{
|
||||
ExpandedGenre = ExpandedGenre == genre ? null : genre;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the cached genre dataset so the next <see cref="SwitchModeAsync"/> into Genre mode
|
||||
/// re-fetches from the API. Call after a track or release mutation (edit, delete) since the genre
|
||||
/// summaries are derived from the catalogue and go stale on any such change. The all-releases grid
|
||||
/// owns and refreshes its own data, so it needs no invalidation here.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
Genres = Array.Empty<GenreSummaryDto>();
|
||||
}
|
||||
}
|
||||
@@ -661,50 +661,6 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/track/genres", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for genre summaries");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API genre summaries failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Failed to load genres.");
|
||||
}
|
||||
|
||||
List<GenreSummaryDto>? genres;
|
||||
try
|
||||
{
|
||||
genres = await response.Content.ReadFromJsonAsync<List<GenreSummaryDto>>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize genre summaries from Content API response");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (genres is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null genre summaries list");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
|
||||
|
||||
@@ -122,9 +122,6 @@ public interface ICmsTrackService
|
||||
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total track count by calling GET api/track/page with pageSize=1 and reading TotalCount.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user