@using System.Net @using DeepDrftManager.Services @using DeepDrftModels.DTOs @using DeepDrftModels.Enums @inject ICmsTrackService CmsTrackService @inject IDialogService DialogService @inject ISnackbar Snackbar @inject ILogger Logger @if (IsLoading) { } else if (_rows.Count == 0) { No releases found. } else { Art Album Artist Genre Release Date Type Tracks @foreach (var column in SpecialColumns) { @column.Header } Actions @if (!string.IsNullOrEmpty(context.Release.ImagePath)) {
} else {
}
@context.Release.Title @context.Release.Artist @(context.Release.Genre ?? "—") @(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—") @(context.Release.Medium == ReleaseMedium.Cut ? context.Release.ReleaseType?.ToString() ?? "—" : MediumTypeLabels[context.Release.Medium]) @context.TrackCount @foreach (var column in SpecialColumns) { @* One dedicated cell per host-declared special-action column (Mix waveform, Session hero). The Cell fragment recovers its typed row state via the host's RowFor lookup. Sits between Tracks and Actions so the universal Edit/Delete stay rightmost. *@ @column.Cell(context.Release) }
@if (context.IsExpanded) { @if (context.IsLoading) { Loading tracks… } else if (context.Tracks is { Count: 0 }) { No tracks found. } else if (context.Tracks is not null) { # 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)) { } @if (HasHighRes(track.EntryKey)) { } @* 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 } } }
} @code { [Parameter] public IReadOnlyList Releases { get; set; } = Array.Empty(); [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 // columns plus Edit/Delete. A per-medium host supplies its bespoke affordances here so the rich // expand/delete/Type-chip/edit logic stays single-sourced in this grid rather than forked. [Parameter] public IReadOnlyList SpecialColumns { get; set; } = Array.Empty(); // Base columns: expand, Art, Album, Artist, Genre, Release Date, Type, Tracks, Actions = 9. private const int BaseColumnCount = 9; // Total rendered columns, driving the expanded child-row colspan so it always spans the full table // regardless of how many special-action columns the host declared. private int ColumnCount => BaseColumnCount + SpecialColumns.Count; private List _rows = new(); // Tracks the Releases reference last projected into _rows. Guards against OnParametersSet // resurrecting a row we removed locally on delete: while the parent holds the same Releases // instance (e.g. a mid-operation re-render under IsDeleting, before any refresh hands us a new // list), a blind rebuild every render would bring the deleted row back. We only re-project when // the parent hands us a genuinely new list. private IReadOnlyList? _projectedReleases; // Re-project rows only when the parent supplies a genuinely new release list (reference change). // Local edits to _rows (a removed row after delete) must survive re-renders triggered by the // same Releases instance. protected override void OnParametersSet() { if (!ReferenceEquals(_projectedReleases, Releases)) { _projectedReleases = Releases; _rows = Releases.Select(r => new AlbumRow { Release = r }).ToList(); } } // Relative path — resolves against the Manager's own origin, proxied by ImageProxyController. private static string ThumbUrl(string imagePath) => $"/api/image/{Uri.EscapeDataString(imagePath)}"; // Medium → Type-chip display label for non-Cut media. Cut rows show ReleaseType instead. // One entry per non-Cut medium; a future medium adds one line here, no markup change needed. private static readonly IReadOnlyDictionary MediumTypeLabels = new Dictionary { [ReleaseMedium.Session] = "Session", [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; if (row.IsExpanded && row.Tracks is null && !row.IsLoading) { 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; } } // Albums are small releases; a single page of 100 always covers the full track list (see brief). private async Task> LoadTracksAsync(string albumTitle) { var result = await CmsTrackService.GetPagedAsync( page: 1, pageSize: 100, sortColumn: "TrackNumber", sortDescending: false, album: albumTitle); if (result.Success && result.Value is not null) { return result.Value.Items.ToList(); } var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; Snackbar.Add($"Failed to load tracks for '{albumTitle}': {error}", Severity.Error); return new List(); } private async Task ConfirmAndDeleteAlbum(AlbumRow row) { // Need track IDs to delete; load them if the row was never expanded. row.Tracks ??= await LoadTracksAsync(row.Release.Title); var tracks = row.Tracks; var count = tracks.Count; if (count == 0) { // Orphaned release: every track was soft-deleted earlier, leaving a 0-track row that // cannot be cleared by deleting tracks. Delete the release record directly instead. await ConfirmAndDeleteEmptyReleaseAsync(row); return; } var confirmed = await DialogService.ShowMessageBox( title: "Delete release", markupMessage: new MarkupString( $"Delete all {count} track(s) in {WebUtility.HtmlEncode(row.Release.Title)}? This removes metadata and audio for every track."), yesText: "Delete all", cancelText: "Cancel"); if (confirmed != true) return; row.IsDeleting = true; StateHasChanged(); var failures = 0; foreach (var track in tracks) { try { var del = await CmsTrackService.DeleteTrackAsync(track.Id); if (!del.Success) failures++; } catch (Exception ex) { Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id); failures++; } } row.IsDeleting = false; if (failures == 0) { Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success); _rows.Remove(row); await OnReleasesChanged.InvokeAsync(); } else { Snackbar.Add($"{count - failures} of {count} track(s) deleted; {failures} failed.", Severity.Warning); await OnReleasesChanged.InvokeAsync(); } StateHasChanged(); } // Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade // delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the // parent so its release list stays in sync with what is shown. private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row) { var confirmed = await DialogService.ShowMessageBox( title: "Delete release", markupMessage: new MarkupString( $"{WebUtility.HtmlEncode(row.Release.Title)} has no tracks. Delete this empty release record?"), yesText: "Delete", cancelText: "Cancel"); if (confirmed != true) return; row.IsDeleting = true; StateHasChanged(); var result = await CmsTrackService.DeleteReleaseAsync(row.Release.Id); row.IsDeleting = false; if (result.Success) { Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success); _rows.Remove(row); await OnReleasesChanged.InvokeAsync(); } else { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; Snackbar.Add($"Delete failed: {error}", Severity.Error); } StateHasChanged(); } private sealed class AlbumRow { public required ReleaseDto Release { get; init; } public List? Tracks { get; set; } // null = not yet loaded public bool IsExpanded { get; set; } public bool IsLoading { get; set; } public bool IsDeleting { get; set; } // Server-projected count from GetReleasesAsync. Drives the Tracks column without a lazy load. public int TrackCount => Release.TrackCount; } }