Consolidate CMS /tracks into standalone /releases page
Retire the Tracks list view; promote the Releases view to /releases with medium tabs (ALL/CUTS/SESSIONS/MIXES). Migrate bulk profile/high-res backfill and per-track waveform columns into the releases grids. Point catalogue cards at the three mediums. Remove dead BrowseMode/ViewModel.
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
@page "/catalogue"
|
@page "/catalogue"
|
||||||
@using DeepDrftManager.Services
|
@using DeepDrftManager.Services
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@layout Layout.CmsLayout
|
@layout Layout.CmsLayout
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject ICmsTrackService CmsTrackService
|
@inject ICmsReleaseService CmsReleaseService
|
||||||
@inject ILogger<Index> Logger
|
@inject ILogger<Index> Logger
|
||||||
|
|
||||||
<PageTitle>DeepDrft CMS</PageTitle>
|
<PageTitle>DeepDrft CMS</PageTitle>
|
||||||
@@ -12,114 +13,91 @@
|
|||||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||||
|
|
||||||
<MudGrid Spacing="4">
|
<MudGrid Spacing="4">
|
||||||
<MudItem xs="12" sm="4">
|
@foreach (var card in Cards)
|
||||||
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
|
{
|
||||||
</MudItem>
|
<MudItem xs="12" sm="4">
|
||||||
<MudItem xs="12" sm="4">
|
@SummaryCard(card)
|
||||||
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
|
</MudItem>
|
||||||
</MudItem>
|
}
|
||||||
<MudItem xs="12" sm="4">
|
|
||||||
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool _tracksLoading = true;
|
// One card per release medium. Each deep-links to /releases with the medium tab pre-selected via the
|
||||||
private bool _albumsLoading = true;
|
// same ?medium= convention the Add Track buttons use. The count is that medium's release total.
|
||||||
private bool _genresLoading = true;
|
private sealed record MediumCard(ReleaseMedium Medium, string Label, string Icon, Color Color);
|
||||||
|
|
||||||
private int? _trackCount;
|
private static readonly IReadOnlyList<MediumCard> Cards = new[]
|
||||||
private int? _albumCount;
|
{
|
||||||
private int? _genreCount;
|
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<ReleaseMedium, int?> _counts = new();
|
||||||
|
private readonly HashSet<ReleaseMedium> _loading = Cards.Select(c => c.Medium).ToHashSet();
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
|
// Each loader calls StateHasChanged in its finally block so its card updates as soon as its own
|
||||||
// finally block so its card updates as soon as its own fetch returns.
|
// fetch returns, rather than blocking on the slowest of the three.
|
||||||
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
|
await Task.WhenAll(Cards.Select(c => LoadCountAsync(c.Medium)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadTrackCount()
|
private async Task LoadCountAsync(ReleaseMedium medium)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await CmsTrackService.GetTrackCountAsync();
|
// pageSize 1 — we only need TotalCount, not the rows. Sort column is required by the API but
|
||||||
_trackCount = result.Success ? result.Value : null;
|
// 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)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
Logger.LogWarning("Dashboard track count failed: {Error}",
|
Logger.LogWarning("Dashboard {Medium} count failed: {Error}",
|
||||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
medium, result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_tracksLoading = false;
|
_loading.Remove(medium);
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAlbumCount()
|
private RenderFragment SummaryCard(MediumCard card) => __builder =>
|
||||||
{
|
|
||||||
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 =>
|
|
||||||
{
|
{
|
||||||
|
var loading = _loading.Contains(card.Medium);
|
||||||
|
var count = _counts.GetValueOrDefault(card.Medium);
|
||||||
<MudCard Elevation="8" Style="height: 100%;">
|
<MudCard Elevation="8" Style="height: 100%;">
|
||||||
<MudCardContent>
|
<MudCardContent>
|
||||||
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
|
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
|
||||||
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
|
<MudIcon Icon="@card.Icon" Color="@card.Color" Size="Size.Large" />
|
||||||
@if (loading)
|
@if (loading)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
|
<MudProgressCircular Color="@card.Color" Indeterminate="true" Size="Size.Small" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
|
<MudText Typo="Typo.h3" Color="@card.Color">@(count?.ToString() ?? "—")</MudText>
|
||||||
}
|
}
|
||||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
|
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@card.Label</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudCardContent>
|
</MudCardContent>
|
||||||
<MudCardActions Class="justify-center pb-4">
|
<MudCardActions Class="justify-center pb-4">
|
||||||
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
<MudButton Variant="Variant.Text" Color="@card.Color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||||
OnClick="@(() => Nav.NavigateTo("/tracks"))">
|
OnClick="@(() => Nav.NavigateTo(ReleasesHref(card.Medium)))">
|
||||||
View
|
View
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudCardActions>
|
</MudCardActions>
|
||||||
</MudCard>
|
</MudCard>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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()}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
@inject ICmsTrackService CmsTrackService
|
@inject ICmsTrackService CmsTrackService
|
||||||
@inject CmsTrackBrowserViewModel VM
|
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@@ -87,7 +86,7 @@
|
|||||||
|
|
||||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||||
<MudButton Variant="Variant.Text"
|
<MudButton Variant="Variant.Text"
|
||||||
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
|
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||||
Disabled="_saving">
|
Disabled="_saving">
|
||||||
Cancel
|
Cancel
|
||||||
</MudButton>
|
</MudButton>
|
||||||
@@ -564,15 +563,10 @@
|
|||||||
StateHasChanged();
|
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)
|
if (failed == 0)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
||||||
Navigation.NavigateTo("/tracks/albums");
|
Navigation.NavigateTo("/releases");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject ILogger<BatchUpload> Logger
|
@inject ILogger<BatchUpload> Logger
|
||||||
@inject CmsTrackBrowserViewModel VM
|
|
||||||
|
|
||||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@
|
|||||||
|
|
||||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||||
<MudButton Variant="Variant.Text"
|
<MudButton Variant="Variant.Text"
|
||||||
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
|
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||||
Disabled="_uploading">
|
Disabled="_uploading">
|
||||||
Cancel
|
Cancel
|
||||||
</MudButton>
|
</MudButton>
|
||||||
@@ -484,8 +483,7 @@
|
|||||||
if (failed == 0)
|
if (failed == 0)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
|
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
|
||||||
VM.Invalidate();
|
Navigation.NavigateTo("/releases");
|
||||||
Navigation.NavigateTo("/tracks");
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,10 +114,48 @@ else
|
|||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
||||||
<MudTh>Track Name</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>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
||||||
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
||||||
|
<MudTd DataLabel="Profile">
|
||||||
|
@if (HasProfile(track.EntryKey))
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTooltip Text="Generate profile">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
Disabled="@_generating.Contains(track.EntryKey)"
|
||||||
|
OnClick="@(() => GenerateProfileAsync(track))" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="High-res">
|
||||||
|
@if (HasHighRes(track.EntryKey))
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTooltip Text="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>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
}
|
}
|
||||||
@@ -181,6 +219,101 @@ else
|
|||||||
[ReleaseMedium.Mix] = "DJ Mix",
|
[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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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)
|
private async Task ToggleExpand(AlbumRow row)
|
||||||
{
|
{
|
||||||
row.IsExpanded = !row.IsExpanded;
|
row.IsExpanded = !row.IsExpanded;
|
||||||
@@ -189,6 +322,9 @@ else
|
|||||||
row.IsLoading = true;
|
row.IsLoading = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
row.Tracks = await LoadTracksAsync(row.Release.Title);
|
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;
|
row.IsLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
@using DeepDrftModels.DTOs
|
|
||||||
|
|
||||||
@if (IsLoading)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
|
||||||
}
|
|
||||||
else if (Genres.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudGrid Spacing="3" Class="mt-2">
|
|
||||||
@foreach (var genre in Genres)
|
|
||||||
{
|
|
||||||
var isExpanded = ExpandedGenre == genre.Genre;
|
|
||||||
<MudItem xs="12" sm="6" md="4">
|
|
||||||
<MudCard Elevation="@(isExpanded ? 4 : 1)"
|
|
||||||
Style="cursor: pointer;"
|
|
||||||
@onclick="@(() => ToggleGenre(genre.Genre))">
|
|
||||||
<div class="@SwatchClass(isExpanded)"></div>
|
|
||||||
<MudCardContent>
|
|
||||||
<MudText Typo="Typo.h6">@genre.Genre</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
|
|
||||||
</MudCardContent>
|
|
||||||
</MudCard>
|
|
||||||
</MudItem>
|
|
||||||
}
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
@if (ExpandedGenre is not null)
|
|
||||||
{
|
|
||||||
<MudDivider Class="my-4" />
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
|
|
||||||
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
|
|
||||||
[Parameter] public bool IsLoading { get; set; }
|
|
||||||
[Parameter] public string? ExpandedGenre { get; set; }
|
|
||||||
[Parameter] public EventCallback<string?> 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";
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -23,9 +23,9 @@ else
|
|||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudButton Variant="Variant.Text"
|
<MudButton Variant="Variant.Text"
|
||||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
Href="/tracks/archive"
|
Href="/releases"
|
||||||
Class="mb-4">
|
Class="mb-4">
|
||||||
Back to Release Archive
|
Back to Releases
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
|
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ else
|
|||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudButton Variant="Variant.Text"
|
<MudButton Variant="Variant.Text"
|
||||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
Href="/tracks/archive"
|
Href="/releases"
|
||||||
Class="mb-4">
|
Class="mb-4">
|
||||||
Back to Release Archive
|
Back to Releases
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
|
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
@using System.Net
|
|
||||||
@using DeepDrftManager.Services
|
|
||||||
@using DeepDrftModels.DTOs
|
|
||||||
@inject ICmsTrackService CmsTrackService
|
|
||||||
@inject IDialogService DialogService
|
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
@inject ILogger<CmsTrackGrid> Logger
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
@if (ShowAddButton)
|
|
||||||
{
|
|
||||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
|
||||||
<MudButton Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Add"
|
|
||||||
Href="/tracks/upload">
|
|
||||||
Add Track
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
|
|
||||||
<MudTable T="TrackDto"
|
|
||||||
@ref="_table"
|
|
||||||
ServerData="LoadServerData"
|
|
||||||
Hover="true"
|
|
||||||
Striped="true"
|
|
||||||
Dense="true"
|
|
||||||
Bordered="false"
|
|
||||||
FixedHeader="true"
|
|
||||||
RowsPerPage="@PageSize"
|
|
||||||
AllowUnsorted="false">
|
|
||||||
<NoRecordsContent>
|
|
||||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
|
||||||
</NoRecordsContent>
|
|
||||||
<LoadingContent>
|
|
||||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
|
||||||
</LoadingContent>
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">Track #</MudTh>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">Art</MudTh>
|
|
||||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
|
||||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
|
||||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
|
||||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
|
||||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Track #">@context.TrackNumber</MudTd>
|
|
||||||
<MudTd DataLabel="Art">
|
|
||||||
@if (!string.IsNullOrEmpty(context.Release?.ImagePath))
|
|
||||||
{
|
|
||||||
<div class="cms-track-thumb"
|
|
||||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="cms-track-thumb cms-track-thumb--fallback"></div>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
|
||||||
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
|
|
||||||
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
|
||||||
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
|
||||||
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
|
||||||
<MudTd DataLabel="Profile">
|
|
||||||
@if (HasProfile(context.EntryKey))
|
|
||||||
{
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="High-res">
|
|
||||||
@if (HasHighRes(context.EntryKey))
|
|
||||||
{
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Actions">
|
|
||||||
<MudTooltip Text="Edit">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Primary"
|
|
||||||
Href="@($"/tracks/{context.Id}/edit")" />
|
|
||||||
</MudTooltip>
|
|
||||||
<MudTooltip Text="Delete">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Error"
|
|
||||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
|
||||||
</MudTooltip>
|
|
||||||
<MudTooltip>
|
|
||||||
<TooltipContent>
|
|
||||||
<div class="cms-track-info">
|
|
||||||
<div>Entry: @context.EntryKey</div>
|
|
||||||
<div>File: @(context.OriginalFileName ?? "—")</div>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
<ChildContent>
|
|
||||||
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" />
|
|
||||||
</ChildContent>
|
|
||||||
</MudTooltip>
|
|
||||||
@if (!HasProfile(context.EntryKey))
|
|
||||||
{
|
|
||||||
<MudTooltip Text="Generate profile">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Secondary"
|
|
||||||
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
|
|
||||||
OnClick="@(() => GenerateOneAsync(context))" />
|
|
||||||
</MudTooltip>
|
|
||||||
}
|
|
||||||
@if (!HasHighRes(context.EntryKey))
|
|
||||||
{
|
|
||||||
<MudTooltip Text="Generate high-res datum">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Waves"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Secondary"
|
|
||||||
Disabled="@(_bulkRunning || _generatingHighRes.Contains(context.EntryKey))"
|
|
||||||
OnClick="@(() => GenerateOneHighResAsync(context))" />
|
|
||||||
</MudTooltip>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
<PagerContent>
|
|
||||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
|
||||||
</PagerContent>
|
|
||||||
</MudTable>
|
|
||||||
|
|
||||||
@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<TrackDto>? _table;
|
|
||||||
|
|
||||||
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
|
|
||||||
private Dictionary<string, bool> _waveformStatus = new();
|
|
||||||
// EntryKey → HasHighRes (the per-track visualizer datum, phase-12 §5). Same lifecycle as above.
|
|
||||||
private Dictionary<string, bool> _highResStatus = new();
|
|
||||||
private readonly HashSet<string> _generating = new();
|
|
||||||
private readonly HashSet<string> _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)}";
|
|
||||||
|
|
||||||
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
|
|
||||||
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
|
|
||||||
|
|
||||||
/// <summary>Number of tracks missing the high-res visualizer datum — drives the parent's backfill button.</summary>
|
|
||||||
public int GetMissingHighResCount() => _highResStatus.Count(kv => !kv.Value);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<string, bool>();
|
|
||||||
_highResStatus = new Dictionary<string, bool>();
|
|
||||||
}
|
|
||||||
|
|
||||||
StateHasChanged();
|
|
||||||
await OnStatusLoaded.InvokeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Set by the parent while its bulk generate runs so per-row buttons disable.</summary>
|
|
||||||
public void SetBulkRunning(bool running)
|
|
||||||
{
|
|
||||||
_bulkRunning = running;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<TableData<TrackDto>> 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<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
var page = result.Value;
|
|
||||||
return new TableData<TrackDto>
|
|
||||||
{
|
|
||||||
Items = page.Items,
|
|
||||||
TotalItems = page.TotalCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ConfirmAndDelete(TrackDto track)
|
|
||||||
{
|
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
|
||||||
title: "Delete track",
|
|
||||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
@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<Releases> Logger
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>Releases — DeepDrft CMS</PageTitle>
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||||
|
<MudText Typo="Typo.h3">Releases</MudText>
|
||||||
|
|
||||||
|
@* 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. *@
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||||
|
Disabled="@(_bulkRunning || _highResBulkRunning || MissingProfileCount == 0)"
|
||||||
|
OnClick="GenerateAllMissingAsync">
|
||||||
|
@if (_bulkRunning)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||||
|
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Generate All Profiles (@MissingProfileCount)</span>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Waves"
|
||||||
|
Disabled="@(_bulkRunning || _highResBulkRunning || MissingHighResCount == 0)"
|
||||||
|
OnClick="GenerateAllMissingHighResAsync">
|
||||||
|
@if (_highResBulkRunning)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||||
|
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Backfill High-res (@MissingHighResCount)</span>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@* Medium tab strip: 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. *@
|
||||||
|
@* 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. *@
|
||||||
|
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="@AddTrackHref(ActiveMedium)">
|
||||||
|
Add Track
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
|
||||||
|
@bind-ActivePanelIndex="_activeTabIndex">
|
||||||
|
<MudTabPanel Text="ALL">
|
||||||
|
<CmsAllReleasesGrid />
|
||||||
|
</MudTabPanel>
|
||||||
|
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
||||||
|
{
|
||||||
|
<MudTabPanel Text="@MediumTabLabels[medium]">
|
||||||
|
@MediumGrid(medium)
|
||||||
|
</MudTabPanel>
|
||||||
|
}
|
||||||
|
</MudTabs>
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// Active tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() 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<ReleaseMedium>()[_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. The ALL tab is rendered separately (it is not a
|
||||||
|
// medium).
|
||||||
|
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
|
||||||
|
new Dictionary<ReleaseMedium, string>
|
||||||
|
{
|
||||||
|
[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 => @<CmsCutBrowser />,
|
||||||
|
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
|
||||||
|
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
|
||||||
|
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<WaveformStatusDto> _waveformStatus = Array.Empty<WaveformStatusDto>();
|
||||||
|
|
||||||
|
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<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
||||||
|
&& Enum.IsDefined(medium))
|
||||||
|
{
|
||||||
|
_activeTabIndex = Array.IndexOf(Enum.GetValues<ReleaseMedium>(), 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<WaveformStatusDto>();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TrackList> Logger
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
@attribute [Authorize]
|
|
||||||
|
|
||||||
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
|
||||||
<MudText Typo="Typo.h3">Tracks</MudText>
|
|
||||||
|
|
||||||
@if (VM.Mode == BrowseMode.Tracks)
|
|
||||||
{
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
|
||||||
<MudButton Variant="Variant.Outlined"
|
|
||||||
Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
|
||||||
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
|
||||||
OnClick="GenerateAllMissingAsync">
|
|
||||||
@if (_bulkRunning)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
|
||||||
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span>Generate All Profiles (@(_grid?.GetMissingCount() ?? 0))</span>
|
|
||||||
}
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Variant="Variant.Outlined"
|
|
||||||
Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Waves"
|
|
||||||
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingHighResCount() ?? 0) == 0)"
|
|
||||||
OnClick="GenerateAllMissingHighResAsync">
|
|
||||||
@if (_highResBulkRunning)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
|
||||||
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span>Backfill High-res (@(_grid?.GetMissingHighResCount() ?? 0))</span>
|
|
||||||
}
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
|
|
||||||
@* 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. *@
|
|
||||||
<MudToggleGroup T="BrowseMode"
|
|
||||||
Value="VM.Mode"
|
|
||||||
ValueChanged="OnModeChanged"
|
|
||||||
SelectionMode="SelectionMode.SingleSelection"
|
|
||||||
Color="Color.Primary"
|
|
||||||
Size="Size.Small"
|
|
||||||
Class="mb-4">
|
|
||||||
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
|
|
||||||
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
|
|
||||||
</MudToggleGroup>
|
|
||||||
|
|
||||||
@if (VM.Mode == BrowseMode.Tracks)
|
|
||||||
{
|
|
||||||
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
|
|
||||||
}
|
|
||||||
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. *@
|
|
||||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
|
||||||
<MudButton Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Add"
|
|
||||||
Href="@AddTrackHref(ActiveMedium)">
|
|
||||||
Add Track
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
|
|
||||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
|
|
||||||
@bind-ActivePanelIndex="_activeTabIndex">
|
|
||||||
<MudTabPanel Text="ALL">
|
|
||||||
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
|
|
||||||
</MudTabPanel>
|
|
||||||
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
|
||||||
{
|
|
||||||
<MudTabPanel Text="@MediumTabLabels[medium]">
|
|
||||||
@MediumGrid(medium)
|
|
||||||
</MudTabPanel>
|
|
||||||
}
|
|
||||||
</MudTabs>
|
|
||||||
}
|
|
||||||
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. *@
|
|
||||||
<CmsGenreBrowser Genres="VM.Genres"
|
|
||||||
IsLoading="VM.GenresLoading"
|
|
||||||
ExpandedGenre="@VM.ExpandedGenre"
|
|
||||||
OnExpandedGenreChanged="OnExpandedGenreChanged" />
|
|
||||||
}
|
|
||||||
</MudContainer>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private CmsTrackGrid? _grid;
|
|
||||||
|
|
||||||
// Active Release-Archive tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() 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<ReleaseMedium>()[_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<ReleaseMedium, string> MediumTabLabels =
|
|
||||||
new Dictionary<ReleaseMedium, string>
|
|
||||||
{
|
|
||||||
[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 => @<CmsCutBrowser />,
|
|
||||||
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
|
|
||||||
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
|
|
||||||
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,9 +27,6 @@ builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
|||||||
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
|
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
|
||||||
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
|
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
|
||||||
|
|
||||||
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
|
|
||||||
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
|
|
||||||
|
|
||||||
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
|
// 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.
|
// /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.
|
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
using DeepDrftModels.DTOs;
|
|
||||||
|
|
||||||
namespace DeepDrftManager.Services;
|
|
||||||
|
|
||||||
/// <summary>The browse dimensions for the /tracks page.</summary>
|
|
||||||
public enum BrowseMode
|
|
||||||
{
|
|
||||||
Tracks,
|
|
||||||
|
|
||||||
/// <summary>The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A).</summary>
|
|
||||||
Albums,
|
|
||||||
|
|
||||||
Genres,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <c>CmsTrackGrid</c> and needs no
|
|
||||||
/// state here.
|
|
||||||
/// </summary>
|
|
||||||
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<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
|
|
||||||
public bool GenresLoading { get; private set; }
|
|
||||||
public string? ExpandedGenre { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <c>CmsAllReleasesGrid</c>) — so no fetch happens for
|
|
||||||
/// either here.
|
|
||||||
/// </summary>
|
|
||||||
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<GenreSummaryDto>();
|
|
||||||
GenresLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Toggle the expanded genre row. Selecting the already-expanded genre collapses it.</summary>
|
|
||||||
public void SetExpandedGenre(string? genre)
|
|
||||||
{
|
|
||||||
ExpandedGenre = ExpandedGenre == genre ? null : genre;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drop the cached genre dataset so the next <see cref="SwitchModeAsync"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
public void Invalidate()
|
|
||||||
{
|
|
||||||
Genres = Array.Empty<GenreSummaryDto>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user