diff --git a/DeepDrftManager/Components/Pages/Index.razor b/DeepDrftManager/Components/Pages/Index.razor index 8580d71..3bf2d57 100644 --- a/DeepDrftManager/Components/Pages/Index.razor +++ b/DeepDrftManager/Components/Pages/Index.razor @@ -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 Logger DeepDrft CMS @@ -12,114 +13,91 @@ Catalogue - - @SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount) - - - @SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount) - - - @SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount) - + @foreach (var card in Cards) + { + + @SummaryCard(card) + + } @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 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 _counts = new(); + private readonly HashSet _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); - + @if (loading) { - + } else { - @(count?.ToString() ?? "—") + @(count?.ToString() ?? "—") } - @label + @card.Label - + View }; + + // 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()}"; } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index 89d74d7..d51e9c9 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -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 @@ Cancel @@ -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 { diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index a256101..a82085e 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -11,7 +11,6 @@ @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject ILogger Logger -@inject CmsTrackBrowserViewModel VM Upload Release — DeepDrft CMS @@ -87,7 +86,7 @@ Cancel @@ -484,8 +483,7 @@ if (failed == 0) { Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success); - VM.Invalidate(); - Navigation.NavigateTo("/tracks"); + Navigation.NavigateTo("/releases"); } else { diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor index c80127b..fdcc781 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor @@ -114,10 +114,69 @@ else # Track Name + @* 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. *@ + Profile + High-res + @* Info column: per-track EntryKey + OriginalFileName tooltip (migrated + from the retired CmsTrackGrid's .cms-track-info monospace block). *@ + @track.TrackNumber @track.TrackName + + @if (HasProfile(track.EntryKey)) + { + + } + else + { + + + + } + + + @if (HasHighRes(track.EntryKey)) + { + + } + else + { + + + + } + + @* Per-track info tooltip (restored from the retired CmsTrackGrid's + .cms-track-info monospace block): EntryKey + OriginalFileName. *@ + + + + @track.EntryKey + @if (!string.IsNullOrWhiteSpace(track.OriginalFileName)) + { + @track.OriginalFileName + } + + + + + + } @@ -133,6 +192,26 @@ else [Parameter] public bool IsLoading { get; set; } [Parameter] public EventCallback OnReleasesChanged { get; set; } + /// + /// Fires after any per-row waveform generate (profile or high-res) succeeds. The parent page + /// wires this to its own RefreshWaveformStatusAsync so its missing-count badges stay + /// current after an individual-row generate inside an expanded album row. + /// + [Parameter] public EventCallback OnWaveformGenerated { get; set; } + + /// + /// 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. + /// + 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? _profileStatus; + private Dictionary? _highResStatus; + private readonly HashSet _generating = new(); + private readonly HashSet _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(); + _highResStatus = new Dictionary(); + } + } + + 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; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAllReleasesGrid.razor b/DeepDrftManager/Components/Pages/Tracks/CmsAllReleasesGrid.razor index 5e37b0b..a92c7a8 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsAllReleasesGrid.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsAllReleasesGrid.razor @@ -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. *@ - + 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; } + /// + /// Forwarded from the inner : fires after any per-row waveform + /// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges. + /// + [Parameter] public EventCallback OnWaveformGenerated { get; set; } + + private CmsAlbumBrowser? _albumBrowser; private IReadOnlyList _releases = Array.Empty(); private bool _loading = true; + /// + /// 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. + /// + public Task InvalidateWaveformStatusAsync() => + _albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask; + protected override Task OnInitializedAsync() => ReloadAsync(); private async Task OnGridReleasesChanged() diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor index b2c23db..cd295b3 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsCutBrowser.razor @@ -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. *@ - + OnReleasesChanged="ReloadAsync" + OnWaveformGenerated="OnWaveformGenerated" /> @code { + /// + /// Forwarded from the inner : fires after any per-row waveform + /// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges. + /// + [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; + /// + /// 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. + /// + public Task InvalidateWaveformStatusAsync() => + _albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask; + public sealed class CutRow { public required ReleaseDto Release { get; set; } diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsGenreBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsGenreBrowser.razor deleted file mode 100644 index a29e96f..0000000 --- a/DeepDrftManager/Components/Pages/Tracks/CmsGenreBrowser.razor +++ /dev/null @@ -1,52 +0,0 @@ -@using DeepDrftModels.DTOs - -@if (IsLoading) -{ - -} -else if (Genres.Count == 0) -{ - No genres found. -} -else -{ - - @foreach (var genre in Genres) - { - var isExpanded = ExpandedGenre == genre.Genre; - - -
- - @genre.Genre - @genre.TrackCount track(s) - -
-
- } -
- - @if (ExpandedGenre is not null) - { - - @ExpandedGenre - - } -} - -@code { - [Parameter] public IReadOnlyList Genres { get; set; } = Array.Empty(); - [Parameter] public bool IsLoading { get; set; } - [Parameter] public string? ExpandedGenre { get; set; } - [Parameter] public EventCallback 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"; -} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsGenreBrowser.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsGenreBrowser.razor.css deleted file mode 100644 index 34f29a3e..0000000 --- a/DeepDrftManager/Components/Pages/Tracks/CmsGenreBrowser.razor.css +++ /dev/null @@ -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); -} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor index d291411..04faaca 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor @@ -23,9 +23,9 @@ else - Back to Release Archive + Back to Releases Mixes @@ -41,9 +41,24 @@ else /// [Parameter] public bool Embedded { get; set; } + /// + /// Forwarded from the inner : fires after any per-row waveform + /// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges. + /// + [Parameter] public EventCallback OnWaveformGenerated { get; set; } + + private CmsAlbumBrowser? _albumBrowser; + protected override ReleaseMedium Medium => ReleaseMedium.Mix; protected override string MediumNoun => "mixes"; + /// + /// 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. + /// + 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 => @ @; // Allocated once per component instance in OnInitialized (field initializers cannot reference diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor index 7e56266..c0eb71c 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor @@ -24,9 +24,9 @@ else - Back to Release Archive + Back to Releases Sessions @@ -42,9 +42,24 @@ else /// [Parameter] public bool Embedded { get; set; } + /// + /// Forwarded from the inner : fires after any per-row waveform + /// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges. + /// + [Parameter] public EventCallback OnWaveformGenerated { get; set; } + + private CmsAlbumBrowser? _albumBrowser; + protected override ReleaseMedium Medium => ReleaseMedium.Session; protected override string MediumNoun => "sessions"; + /// + /// 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. + /// + 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 => @ @; // Allocated once per component instance in OnInitialized (field initializers cannot reference diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor deleted file mode 100644 index baae668..0000000 --- a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor +++ /dev/null @@ -1,326 +0,0 @@ -@using System.Net -@using DeepDrftManager.Services -@using DeepDrftModels.DTOs -@inject ICmsTrackService CmsTrackService -@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 - Profile - High-res - 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 - { - - } - - - @if (HasHighRes(context.EntryKey)) - { - - } - else - { - - } - - - - - - - - - - -
-
Entry: @context.EntryKey
-
File: @(context.OriginalFileName ?? "—")
-
-
- - - -
- @if (!HasProfile(context.EntryKey)) - { - - - - } - @if (!HasHighRes(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(); - // EntryKey → HasHighRes (the per-track visualizer datum, phase-12 §5). Same lifecycle as above. - private Dictionary _highResStatus = new(); - private readonly HashSet _generating = new(); - private readonly HashSet _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)}"; - - /// Number of tracks with a missing waveform profile — drives the parent's bulk button label. - public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value); - - /// Number of tracks missing the high-res visualizer datum — drives the parent's backfill button. - public int GetMissingHighResCount() => _highResStatus.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. One status fetch populates both the 512-bucket profile - /// map and the high-res datum map. - /// - 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(); - _highResStatus = 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(); - } - } - - 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(); - } - } -} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor.css deleted file mode 100644 index 4b0b61f..0000000 --- a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor.css +++ /dev/null @@ -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; -} diff --git a/DeepDrftManager/Components/Pages/Tracks/Releases.razor b/DeepDrftManager/Components/Pages/Tracks/Releases.razor new file mode 100644 index 0000000..b63e219 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/Releases.razor @@ -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 Logger +@inject NavigationManager NavigationManager +@attribute [Authorize] + +Releases — DeepDrft CMS + + + + Releases + + @* 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. *@ + + + @if (_bulkRunning) + { + + Generating @_bulkDone / @_bulkTotal… + } + else + { + Generate All Profiles (@MissingProfileCount) + } + + + @if (_highResBulkRunning) + { + + Backfilling @_highResBulkDone / @_highResBulkTotal… + } + else + { + Backfill High-res (@MissingHighResCount) + } + + + + + @* 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. *@ + + + Add Track + + + + + + + + + + + + + + + + + + + +@code { + // Active tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues() 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()[_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 MediumTabLabels = + new Dictionary + { + [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 _waveformStatus = Array.Empty(); + + 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(MediumParam, ignoreCase: true, out var medium) + && Enum.IsDefined(medium)) + { + _activeTabIndex = Array.IndexOf(Enum.GetValues(), 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(); + 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); + } + + /// + /// 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. + /// + 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); + } + } + + /// + /// 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. + /// + 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); + } + } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor deleted file mode 100644 index 022a1de..0000000 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ /dev/null @@ -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 Logger -@inject NavigationManager NavigationManager -@attribute [Authorize] - -Tracks — DeepDrft CMS - - - - Tracks - - @if (VM.Mode == BrowseMode.Tracks) - { - - - @if (_bulkRunning) - { - - Generating @_bulkDone / @_bulkTotal… - } - else - { - Generate All Profiles (@(_grid?.GetMissingCount() ?? 0)) - } - - - @if (_highResBulkRunning) - { - - Backfilling @_highResBulkDone / @_highResBulkTotal… - } - else - { - Backfill High-res (@(_grid?.GetMissingHighResCount() ?? 0)) - } - - - } - - - @* 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. *@ - - Tracks - Releases - - - @if (VM.Mode == BrowseMode.Tracks) - { - - } - 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. *@ - - - Add Track - - - - - - - - @foreach (var medium in Enum.GetValues()) - { - - @MediumGrid(medium) - - } - - } - 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. *@ - - } - - -@code { - private CmsTrackGrid? _grid; - - // Active Release-Archive tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues() 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()[_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 MediumTabLabels = - new Dictionary - { - [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 => @, - ReleaseMedium.Session => @, - ReleaseMedium.Mix => @, - _ => @No grid for this medium. - }; - - // 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(); - } - - /// - /// 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 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); - } - } - - /// - /// 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. - /// - 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); - } - } -} diff --git a/DeepDrftManager/Program.cs b/DeepDrftManager/Program.cs index 923f8dd..5e57c51 100644 --- a/DeepDrftManager/Program.cs +++ b/DeepDrftManager/Program.cs @@ -27,9 +27,6 @@ builder.Services.AddScoped(); // DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService. 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 deleted file mode 100644 index d03e121..0000000 --- a/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs +++ /dev/null @@ -1,76 +0,0 @@ -using DeepDrftModels.DTOs; - -namespace DeepDrftManager.Services; - -/// The browse dimensions for the /tracks page. -public enum BrowseMode -{ - Tracks, - - /// The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A). - 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; - - // 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 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 CmsAllReleasesGrid) — so no fetch happens for - /// either here. - /// - 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(); - GenresLoading = false; - } - } - - /// Toggle the expanded genre row. Selecting the already-expanded genre collapses it. - public void SetExpandedGenre(string? genre) - { - ExpandedGenre = ExpandedGenre == genre ? null : genre; - } - - /// - /// Drop the cached genre dataset so the next 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. - /// - public void Invalidate() - { - Genres = Array.Empty(); - } -} diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index a84c971..3e64e10 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -661,50 +661,6 @@ public class CmsTrackService : ICmsTrackService } } - public async Task>> 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>.CreateFailResult("Content API is unreachable."); - } - - using (response) - { - if (!response.IsSuccessStatusCode) - { - _logger.LogError("Content API genre summaries failed: {Status}", (int)response.StatusCode); - return ResultContainer>.CreateFailResult("Failed to load genres."); - } - - List? genres; - try - { - genres = await response.Content.ReadFromJsonAsync>(ct); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deserialize genre summaries from Content API response"); - return ResultContainer>.CreateFailResult("Content API returned an unexpected response."); - } - - if (genres is null) - { - _logger.LogError("Content API returned a null genre summaries list"); - return ResultContainer>.CreateFailResult("Content API returned an empty response."); - } - - return ResultContainer>.CreatePassResult(genres); - } - } - public async Task> GetTrackCountAsync(CancellationToken ct = default) { // Re-use the paged endpoint: a single-item page carries the full TotalCount, so no diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 66246d0..d666fa5 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -122,9 +122,6 @@ public interface ICmsTrackService /// Returns all releases with track counts from GET api/track/albums. Task>> GetReleasesAsync(CancellationToken ct = default); - /// Returns all distinct genres with track counts from GET api/track/genres. - Task>> GetGenreSummariesAsync(CancellationToken ct = default); - /// /// Returns the total track count by calling GET api/track/page with pageSize=1 and reading TotalCount. ///