From 508a522a8d06508b839cbfbb7ec90a3878e0ad09 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 16:17:45 -0400 Subject: [PATCH] feat(cms): add Track Browser foundation with mode toggle and CmsTrackGrid - Extend ICmsTrackService.GetPagedAsync with album/genre filter params - Add CmsTrackBrowserViewModel (DI-scoped) with lazy album/genre load - Extract CmsTrackGrid: 9-column layout, waveform status, per-row generate, info tooltip, album/genre filter params, OnStatusLoaded callback - Restructure TrackList: remove MudTabs, add three @page routes, mode toggle, Generate All Missing button; album/genre stubs for next wave --- .../Pages/Tracks/CmsTrackGrid.razor | 265 ++++++++++++ .../Pages/Tracks/CmsTrackGrid.razor.css | 17 + .../Components/Pages/Tracks/TrackList.razor | 383 +++++------------- DeepDrftManager/Program.cs | 3 + .../Services/CmsTrackBrowserViewModel.cs | 73 ++++ DeepDrftManager/Services/CmsTrackService.cs | 11 +- DeepDrftManager/Services/ICmsTrackService.cs | 5 +- 7 files changed, 466 insertions(+), 291 deletions(-) create mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor create mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor.css create mode 100644 DeepDrftManager/Services/CmsTrackBrowserViewModel.cs diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor new file mode 100644 index 0000000..e2e6946 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor @@ -0,0 +1,265 @@ +@using System.Net +@using DeepDrftManager.Services +@using DeepDrftModels.DTOs +@inject ICmsTrackService CmsTrackService +@inject IHttpClientFactory HttpClientFactory +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject ILogger Logger +@inject NavigationManager NavigationManager + +@if (ShowAddButton) +{ + + + Add Track + + +} + + + + No tracks found. + + + Loading tracks… + + + Track # + Art + Track Name + Artist + Album + Genre + Release Date + Waveform + Actions + + + @context.TrackNumber + + @if (!string.IsNullOrEmpty(context.Release?.ImagePath)) + { +
+ } + else + { +
+ } +
+ @context.TrackName + @(context.Release?.Artist ?? "—") + @(context.Release?.Title ?? "—") + @(context.Release?.Genre ?? "—") + @(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—") + + @if (HasProfile(context.EntryKey)) + { + + } + else + { + + } + + + + + + + + + + +
+
Entry: @context.EntryKey
+
File: @(context.OriginalFileName ?? "—")
+
+
+ + + +
+ @if (!HasProfile(context.EntryKey)) + { + + + + } +
+
+ + + +
+ +@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? _table; + + // EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true. + private Dictionary _waveformStatus = new(); + private readonly HashSet _generating = new(); + + // The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons. + private bool _bulkRunning; + + // The image endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated, so + // the browser hits it directly. Base address comes from the same named client the CMS uses. + private Uri? _contentApiBase; + + protected override async Task OnInitializedAsync() + { + _contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress; + await RefreshWaveformStatusAsync(); + } + + private bool HasProfile(string entryKey) => + _waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile; + + private string? ThumbUrl(string imagePath) => + _contentApiBase is null + ? null + : new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString(); + + /// Number of tracks with a missing waveform profile — drives the parent's bulk button label. + public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value); + + /// + /// 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. + /// + 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(); + + StateHasChanged(); + await OnStatusLoaded.InvokeAsync(); + } + + /// Set by the parent while its bulk generate runs so per-row buttons disable. + public void SetBulkRunning(bool running) + { + _bulkRunning = running; + StateHasChanged(); + } + + private async Task> 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 { Items = Array.Empty(), TotalItems = 0 }; + } + + var page = result.Value; + return new TableData + { + Items = page.Items, + TotalItems = page.TotalCount + }; + } + + private async Task ConfirmAndDelete(TrackDto track) + { + var confirmed = await DialogService.ShowMessageBox( + title: "Delete track", + markupMessage: new MarkupString($"Delete {WebUtility.HtmlEncode(track.TrackName)} 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(); + } + } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor.css new file mode 100644 index 0000000..4b0b61f --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor.css @@ -0,0 +1,17 @@ +.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; +} diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index c59b951..991098a 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -1,282 +1,114 @@ @page "/tracks" -@using System.Net +@page "/tracks/albums" +@page "/tracks/genres" @using DeepDrftManager.Services -@using DeepDrftModels.DTOs -@attribute [Authorize] +@inject CmsTrackBrowserViewModel VM @inject ICmsTrackService CmsTrackService -@inject IDialogService DialogService @inject ISnackbar Snackbar @inject ILogger Logger +@inject NavigationManager NavigationManager +@attribute [Authorize] Tracks — DeepDrft CMS - Tracks + + Tracks - - - - - Add Track - - + @if (VM.Mode == BrowseMode.Tracks) + { + + @if (_bulkRunning) + { + + Generating @_bulkDone / @_bulkTotal… + } + else + { + Generate All Missing (@(_grid?.GetMissingCount() ?? 0)) + } + + } + - - - No tracks found. - - - Loading tracks… - - - Track Name - Artist - Album - Genre - Release Date - Entry Key - File Name - Actions - - - @context.TrackName - @(context.Release?.Artist ?? "—") - @(context.Release?.Title ?? "—") - @(context.Release?.Genre ?? "—") - @(context.Release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—") - @context.EntryKey - @(context.OriginalFileName ?? "—") - - - - - - - - - - - - - - + + Tracks + Albums + Genres + - - - - Waveform Pre-Processing - - Generate loudness profiles for tracks that predate the waveform seeker. - - - - @if (_bulkRunning) - { - - Generating @_bulkDone / @_bulkTotal… - } - else - { - Generate All Missing (@_missingCount) - } - - - - - - No tracks found. - - - Loading waveform status… - - - Track Name - Entry Key - Profile - Actions - - - @context.TrackName - - @context.EntryKey - - - @if (context.HasProfile) - { - Stored - } - else - { - Missing - } - - - @if (!context.HasProfile) - { - - @if (IsGenerating(context.EntryKey)) - { - - Generating… - } - else - { - Generate - } - - } - - - - - + @if (VM.Mode == BrowseMode.Tracks) + { + + } + else if (VM.Mode == BrowseMode.Albums) + { + Album browser — coming in the next wave. + } + else + { + Genre browser — coming in the next wave. + } @code { - // Track list fields - private MudTable? _table; + private CmsTrackGrid? _grid; - // Waveform fields - private List _waveformRows = new(); - private readonly HashSet _generating = new(); - private bool _waveformLoading = true; + // Local state for the parent-owned "Generate All Missing" bulk run. private bool _bulkRunning; private int _bulkTotal; private int _bulkDone; - private int _missingCount => _waveformRows.Count(r => !r.HasProfile); - protected override async Task OnInitializedAsync() { - await LoadWaveformStatus(); + var uri = NavigationManager.Uri; + var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) + ? BrowseMode.Albums + : uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) + ? BrowseMode.Genres + : BrowseMode.Tracks; + await VM.SwitchModeAsync(initial); } - // ── Track list methods ────────────────────────────────────────────────── - - private async Task> LoadServerData(TableState state, CancellationToken cancellationToken) + private async Task OnModeChanged(BrowseMode mode) { - 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, cancellationToken); - - if (!result.Success || result.Value is null) + await VM.SwitchModeAsync(mode); + var path = mode switch { - var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error); - return new TableData { Items = Array.Empty(), TotalItems = 0 }; - } - - var page = result.Value; - return new TableData - { - Items = page.Items, - TotalItems = page.TotalCount + BrowseMode.Albums => "/tracks/albums", + BrowseMode.Genres => "/tracks/genres", + _ => "/tracks" }; + NavigationManager.NavigateTo(path, replace: true); + StateHasChanged(); } - private async Task ConfirmAndDelete(TrackDto track) + /// + /// 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. + /// + private async Task GenerateAllMissingAsync() { - var confirmed = await DialogService.ShowMessageBox( - title: "Delete track", - markupMessage: new MarkupString($"Delete {WebUtility.HtmlEncode(track.TrackName)} 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 statusResult = await CmsTrackService.GetWaveformStatusAsync(); + if (!statusResult.Success || statusResult.Value is null) { - 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(); - } - 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); - } - } - - // ── Waveform pre-processing methods ──────────────────────────────────── - - private async Task LoadWaveformStatus() - { - _waveformLoading = true; - var result = await CmsTrackService.GetWaveformStatusAsync(); - _waveformLoading = false; - - if (!result.Success || result.Value is null) - { - var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error); - _waveformRows = new List(); return; } - _waveformRows = result.Value.OrderBy(r => r.HasProfile).ThenBy(r => r.TrackName).ToList(); - } - - private bool IsGenerating(string entryKey) => _generating.Contains(entryKey); - - private async Task GenerateOne(WaveformStatusDto row) - { - if (!await GenerateForRow(row)) - { - return; - } - - Snackbar.Add($"Generated profile for '{row.TrackName}'.", Severity.Success); - } - - private async Task GenerateAllMissing() - { - var missing = _waveformRows.Where(r => !r.HasProfile).ToList(); + var missing = statusResult.Value.Where(s => !s.HasProfile).ToList(); if (missing.Count == 0) { return; @@ -285,14 +117,22 @@ _bulkRunning = true; _bulkTotal = missing.Count; _bulkDone = 0; + _grid?.SetBulkRunning(true); var failures = 0; - // Sequential by design: one request at a time so a large backfill does not flood the API - // with concurrent WAV decodes. - foreach (var row in missing) + foreach (var status in missing) { - if (!await GenerateForRow(row)) + 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++; @@ -300,6 +140,12 @@ } _bulkRunning = false; + _grid?.SetBulkRunning(false); + + if (_grid is not null) + { + await _grid.RefreshWaveformStatusAsync(); + } var succeeded = missing.Count - failures; if (failures == 0) @@ -311,45 +157,4 @@ Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning); } } - - /// - /// Runs generation for a single row, flipping its status on success. Returns false on failure - /// (a snackbar is raised here for the per-row path; the bulk path aggregates a summary). Marks - /// the row busy for the duration so its button shows a spinner and stays disabled. - /// - private async Task GenerateForRow(WaveformStatusDto row) - { - _generating.Add(row.EntryKey); - StateHasChanged(); - try - { - var result = await CmsTrackService.GenerateWaveformProfileAsync(row.EntryKey); - if (result.Success) - { - row.HasProfile = true; - return true; - } - - var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - if (!_bulkRunning) - { - Snackbar.Add($"Generate failed for '{row.TrackName}': {error}", Severity.Error); - } - return false; - } - catch (Exception ex) - { - Logger.LogError(ex, "Waveform generation failed for {EntryKey}", row.EntryKey); - if (!_bulkRunning) - { - Snackbar.Add($"Generate failed for '{row.TrackName}' — please try again.", Severity.Error); - } - return false; - } - finally - { - _generating.Remove(row.EntryKey); - StateHasChanged(); - } - } } diff --git a/DeepDrftManager/Program.cs b/DeepDrftManager/Program.cs index 2b68a0a..1b6b778 100644 --- a/DeepDrftManager/Program.cs +++ b/DeepDrftManager/Program.cs @@ -23,6 +23,9 @@ builder.Services.AddMudServices(); // DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer. builder.Services.AddScoped(); +// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets). +builder.Services.AddScoped(); + // 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. diff --git a/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs b/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs new file mode 100644 index 0000000..8f8e368 --- /dev/null +++ b/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs @@ -0,0 +1,73 @@ +using DeepDrftModels.DTOs; + +namespace DeepDrftManager.Services; + +/// The three browse dimensions for the /tracks page. +public enum BrowseMode +{ + Tracks, + Albums, + Genres, +} + +/// +/// 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 CmsTrackGrid and needs no +/// state here. +/// +public class CmsTrackBrowserViewModel +{ + private readonly ICmsTrackService _trackService; + + public CmsTrackBrowserViewModel(ICmsTrackService trackService) + { + _trackService = trackService; + } + + public BrowseMode Mode { get; private set; } = BrowseMode.Tracks; + + // Album mode. + public IReadOnlyList Albums { get; private set; } = Array.Empty(); + public bool AlbumsLoading { get; private set; } + + // Genre mode. + public IReadOnlyList Genres { get; private set; } = Array.Empty(); + public bool GenresLoading { get; private set; } + public string? ExpandedGenre { get; private set; } + + /// + /// Switch the active mode, lazily loading the album or genre dataset on first entry. Collapses + /// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there. + /// + public async Task SwitchModeAsync(BrowseMode mode) + { + Mode = mode; + ExpandedGenre = null; // collapse on mode switch + + if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading) + { + AlbumsLoading = true; + var result = await _trackService.GetReleasesAsync(); + Albums = result.Success && result.Value is not null + ? result.Value + : Array.Empty(); + AlbumsLoading = false; + } + else 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(); + GenresLoading = false; + } + } + + /// Toggle the expanded genre row. Selecting the already-expanded genre collapses it. + public void SetExpandedGenre(string? genre) + { + ExpandedGenre = ExpandedGenre == genre ? null : genre; + } +} diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index c102ec2..e2f38c7 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -154,6 +154,7 @@ public class CmsTrackService : ICmsTrackService public async Task>> GetPagedAsync( int page, int pageSize, string? sortColumn, bool sortDescending, + string? album = null, string? genre = null, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); @@ -162,6 +163,14 @@ public class CmsTrackService : ICmsTrackService { query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}"; } + if (!string.IsNullOrWhiteSpace(album)) + { + query += $"&album={Uri.EscapeDataString(album)}"; + } + if (!string.IsNullOrWhiteSpace(genre)) + { + query += $"&genre={Uri.EscapeDataString(genre)}"; + } HttpResponseMessage response; try @@ -542,7 +551,7 @@ public class CmsTrackService : ICmsTrackService { // Re-use the paged endpoint: a single-item page carries the full TotalCount, so no // dedicated count endpoint is needed. - var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct); + var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct: ct); if (!paged.Success || paged.Value is null) { var error = paged.Messages.FirstOrDefault()?.Message ?? "Failed to load track count."; diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index a8f7fbd..f407b9f 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -41,10 +41,13 @@ public interface ICmsTrackService Task DeleteTrackAsync(long id, CancellationToken ct = default); /// - /// Fetch a page of track metadata from the Content API's GET api/track/page. + /// Fetch a page of track metadata from the Content API's GET api/track/page. Optional + /// and filters narrow the result to a single + /// release title or genre; null leaves the dimension unfiltered. /// Task>> GetPagedAsync( int page, int pageSize, string? sortColumn, bool sortDescending, + string? album = null, string? genre = null, CancellationToken ct = default); ///