fc32791cea
MixBrowser WaveformCell: wrap icon+button in MudStack Row. SessionBrowser HeroCell: split into two SpecialActionColumns (thumb + button). AlbumBrowser track table: always show regenerate button for Profile and High-res.
500 lines
24 KiB
Plaintext
500 lines
24 KiB
Plaintext
@using System.Net
|
|
@using DeepDrftManager.Services
|
|
@using DeepDrftModels.DTOs
|
|
@using DeepDrftModels.Enums
|
|
@inject ICmsTrackService CmsTrackService
|
|
@inject IDialogService DialogService
|
|
@inject ISnackbar Snackbar
|
|
@inject ILogger<CmsAlbumBrowser> Logger
|
|
|
|
@if (IsLoading)
|
|
{
|
|
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
|
}
|
|
else if (_rows.Count == 0)
|
|
{
|
|
<MudText Typo="Typo.body1" Class="mt-4">No releases found.</MudText>
|
|
}
|
|
else
|
|
{
|
|
<MudTable T="AlbumRow"
|
|
Items="_rows"
|
|
Hover="true"
|
|
Striped="true"
|
|
Dense="true"
|
|
Bordered="false"
|
|
FixedHeader="true">
|
|
<HeaderContent>
|
|
<MudTh Style="width: 1%;"></MudTh>
|
|
<MudTh Style="width: 1%;">Art</MudTh>
|
|
<MudTh>Album</MudTh>
|
|
<MudTh>Artist</MudTh>
|
|
<MudTh>Genre</MudTh>
|
|
<MudTh>Release Date</MudTh>
|
|
<MudTh>Type</MudTh>
|
|
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
|
@foreach (var column in SpecialColumns)
|
|
{
|
|
<MudTh Style="width: 1%; white-space: nowrap;">@column.Header</MudTh>
|
|
}
|
|
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd>
|
|
<MudIconButton Icon="@(context.IsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
|
Size="Size.Small"
|
|
OnClick="@(() => ToggleExpand(context))" />
|
|
</MudTd>
|
|
<MudTd DataLabel="Art">
|
|
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
|
|
{
|
|
<div class="cms-album-thumb"
|
|
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
|
}
|
|
else
|
|
{
|
|
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
|
}
|
|
</MudTd>
|
|
<MudTd DataLabel="Album">@context.Release.Title</MudTd>
|
|
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
|
|
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
|
|
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
|
<MudTd DataLabel="Type">
|
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">
|
|
@(context.Release.Medium == ReleaseMedium.Cut
|
|
? context.Release.ReleaseType?.ToString() ?? "—"
|
|
: MediumTypeLabels[context.Release.Medium])
|
|
</MudChip>
|
|
</MudTd>
|
|
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
|
@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. *@
|
|
<MudTd DataLabel="@column.Header">@column.Cell(context.Release)</MudTd>
|
|
}
|
|
<MudTd DataLabel="Actions">
|
|
<MudTooltip Text="Batch Edit">
|
|
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
|
Size="Size.Small"
|
|
Color="Color.Primary"
|
|
Href="@($"/tracks/album/{Uri.EscapeDataString(context.Release.Title)}/edit")" />
|
|
</MudTooltip>
|
|
<MudTooltip Text="Delete release">
|
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
|
Size="Size.Small"
|
|
Color="Color.Error"
|
|
Disabled="@context.IsDeleting"
|
|
OnClick="@(() => ConfirmAndDeleteAlbum(context))" />
|
|
</MudTooltip>
|
|
</MudTd>
|
|
</RowTemplate>
|
|
<ChildRowContent>
|
|
@if (context.IsExpanded)
|
|
{
|
|
<MudTr>
|
|
<MudTd colspan="@ColumnCount" Style="padding: 0;">
|
|
@if (context.IsLoading)
|
|
{
|
|
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
|
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
|
|
<MudText Typo="Typo.body2">Loading tracks…</MudText>
|
|
</MudStack>
|
|
}
|
|
else if (context.Tracks is { Count: 0 })
|
|
{
|
|
<MudText Typo="Typo.body2" Class="pa-4">No tracks found.</MudText>
|
|
}
|
|
else if (context.Tracks is not null)
|
|
{
|
|
<MudTable T="TrackDto" Items="context.Tracks" Context="track" Dense="true" Hover="false"
|
|
Elevation="0" Style="background: transparent;">
|
|
<HeaderContent>
|
|
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
|
<MudTh>Track Name</MudTh>
|
|
@* 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. *@
|
|
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
|
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
|
|
@* Info column: per-track EntryKey + OriginalFileName tooltip (migrated
|
|
from the retired CmsTrackGrid's .cms-track-info monospace block). *@
|
|
<MudTh Style="width: 1%;"></MudTh>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
|
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
|
<MudTd DataLabel="Profile">
|
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
|
@if (HasProfile(track.EntryKey))
|
|
{
|
|
<MudTooltip Text="Profile generated">
|
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
|
</MudTooltip>
|
|
}
|
|
<MudTooltip Text="@(HasProfile(track.EntryKey) ? "Regenerate profile" : "Generate profile")">
|
|
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
|
Size="Size.Small"
|
|
Color="Color.Secondary"
|
|
Disabled="@_generating.Contains(track.EntryKey)"
|
|
OnClick="@(() => GenerateProfileAsync(track))" />
|
|
</MudTooltip>
|
|
</MudStack>
|
|
</MudTd>
|
|
<MudTd DataLabel="High-res">
|
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
|
@if (HasHighRes(track.EntryKey))
|
|
{
|
|
<MudTooltip Text="High-res datum generated">
|
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
|
</MudTooltip>
|
|
}
|
|
<MudTooltip Text="@(HasHighRes(track.EntryKey) ? "Regenerate high-res datum" : "Generate high-res datum")">
|
|
<MudIconButton Icon="@Icons.Material.Filled.Waves"
|
|
Size="Size.Small"
|
|
Color="Color.Secondary"
|
|
Disabled="@_generatingHighRes.Contains(track.EntryKey)"
|
|
OnClick="@(() => GenerateHighResAsync(track))" />
|
|
</MudTooltip>
|
|
</MudStack>
|
|
</MudTd>
|
|
@* Per-track info tooltip (restored from the retired CmsTrackGrid's
|
|
.cms-track-info monospace block): EntryKey + OriginalFileName. *@
|
|
<MudTd>
|
|
<MudTooltip Placement="Placement.Left">
|
|
<TooltipContent>
|
|
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.EntryKey</MudText>
|
|
@if (!string.IsNullOrWhiteSpace(track.OriginalFileName))
|
|
{
|
|
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.OriginalFileName</MudText>
|
|
}
|
|
</TooltipContent>
|
|
<ChildContent>
|
|
<MudIconButton Icon="@Icons.Material.Outlined.Info"
|
|
Size="Size.Small"
|
|
Color="Color.Default" />
|
|
</ChildContent>
|
|
</MudTooltip>
|
|
</MudTd>
|
|
</RowTemplate>
|
|
</MudTable>
|
|
}
|
|
</MudTd>
|
|
</MudTr>
|
|
}
|
|
</ChildRowContent>
|
|
</MudTable>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public IReadOnlyList<ReleaseDto> Releases { get; set; } = Array.Empty<ReleaseDto>();
|
|
[Parameter] public bool IsLoading { get; set; }
|
|
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
|
|
|
/// <summary>
|
|
/// Fires after any per-row waveform generate (profile or high-res) succeeds. The parent page
|
|
/// wires this to its own <c>RefreshWaveformStatusAsync</c> so its missing-count badges stay
|
|
/// current after an individual-row generate inside an expanded album row.
|
|
/// </summary>
|
|
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<SpecialActionColumn> SpecialColumns { get; set; } = Array.Empty<SpecialActionColumn>();
|
|
|
|
// 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<AlbumRow> _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<ReleaseDto>? _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<ReleaseMedium, string> MediumTypeLabels =
|
|
new Dictionary<ReleaseMedium, string>
|
|
{
|
|
[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<string, bool>? _profileStatus;
|
|
private Dictionary<string, bool>? _highResStatus;
|
|
private readonly HashSet<string> _generating = new();
|
|
private readonly HashSet<string> _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<string, bool>();
|
|
_highResStatus = new Dictionary<string, bool>();
|
|
}
|
|
}
|
|
|
|
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<List<TrackDto>> 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<TrackDto>();
|
|
}
|
|
|
|
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 <strong>{count}</strong> track(s) in <strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong>? 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(
|
|
$"<strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong> 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<TrackDto>? 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;
|
|
}
|
|
}
|