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