@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 — Deep DRFT Management 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) } @* Backfill-Opus (Phase 18.5). Unlike the two waveform buttons, the Opus derive runs on a server-side background worker: the API decides which tracks lack Opus and enqueues them, so there is no client-side "missing N" count to gate on and no per-track progress to render — the action schedules the work and reports the (enqueued / skipped) outcome. Re-runnable: a second press only enqueues tracks still missing Opus. Disabled while a press is in flight. *@ @if (_opusBackfillRunning) { Scheduling… } else { Backfill Opus } @* 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; // Local state for the "Backfill Opus" action. The Opus derive is server-side and background-queued, so // there is no client-side per-track loop or progress total — this flag only guards the button while the // single scheduling call is in flight. private bool _opusBackfillRunning; 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); } } /// /// Kick off the catalogue-wide Backfill-Opus pass. The API enumerates the tracks lacking a complete Opus /// artifact, enqueues a background derive for each, and returns the (enqueued, skipped) counts. This is a /// single scheduling call — the transcodes run server-side afterward — so there is no per-track progress /// to render here, just a busy flag and a result snackbar. Re-runnable: a second press only schedules /// tracks still missing Opus. /// private async Task BackfillOpusAsync() { _opusBackfillRunning = true; StateHasChanged(); try { var result = await CmsTrackService.BackfillOpusAsync(); if (!result.Success) { var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to start the Opus backfill."; Snackbar.Add(error, Severity.Error); return; } var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped); if (enqueued == 0) { Snackbar.Add($"All {skipped} track(s) already have Opus — nothing to backfill.", Severity.Info); } else { Snackbar.Add( $"Scheduled {enqueued} Opus transcode(s) in the background ({skipped} already had Opus). " + "They will appear as each finishes.", Severity.Success); } } catch (Exception ex) { Logger.LogError(ex, "Opus backfill failed to start"); Snackbar.Add("Failed to start the Opus backfill.", Severity.Error); } finally { _opusBackfillRunning = false; StateHasChanged(); } } }