feat(cms): add Track Browser foundation with mode toggle and CmsTrackGrid
- Extend ICmsTrackService.GetPagedAsync with album/genre filter params - Add CmsTrackBrowserViewModel (DI-scoped) with lazy album/genre load - Extract CmsTrackGrid: 9-column layout, waveform status, per-row generate, info tooltip, album/genre filter params, OnStatusLoaded callback - Restructure TrackList: remove MudTabs, add three @page routes, mode toggle, Generate All Missing button; album/genre stubs for next wave
This commit is contained in:
@@ -0,0 +1,265 @@
|
|||||||
|
@using System.Net
|
||||||
|
@using DeepDrftManager.Services
|
||||||
|
@using DeepDrftModels.DTOs
|
||||||
|
@inject ICmsTrackService CmsTrackService
|
||||||
|
@inject IHttpClientFactory HttpClientFactory
|
||||||
|
@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;">Waveform</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="Waveform">
|
||||||
|
@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="Actions">
|
||||||
|
<MudTooltip Text="Edit">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Href="@($"/tracks/{context.Id}")" />
|
||||||
|
</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 Waveform">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
|
||||||
|
OnClick="@(() => GenerateOneAsync(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();
|
||||||
|
private readonly HashSet<string> _generating = new();
|
||||||
|
|
||||||
|
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
|
||||||
|
private bool _bulkRunning;
|
||||||
|
|
||||||
|
// The image endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated, so
|
||||||
|
// the browser hits it directly. Base address comes from the same named client the CMS uses.
|
||||||
|
private Uri? _contentApiBase;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||||
|
await RefreshWaveformStatusAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasProfile(string entryKey) =>
|
||||||
|
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
|
||||||
|
|
||||||
|
private string? ThumbUrl(string imagePath) =>
|
||||||
|
_contentApiBase is null
|
||||||
|
? null
|
||||||
|
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RefreshWaveformStatusAsync()
|
||||||
|
{
|
||||||
|
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||||
|
_waveformStatus = result.Success && result.Value is not null
|
||||||
|
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
|
||||||
|
: 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -1,97 +1,27 @@
|
|||||||
@page "/tracks"
|
@page "/tracks"
|
||||||
@using System.Net
|
@page "/tracks/albums"
|
||||||
|
@page "/tracks/genres"
|
||||||
@using DeepDrftManager.Services
|
@using DeepDrftManager.Services
|
||||||
@using DeepDrftModels.DTOs
|
@inject CmsTrackBrowserViewModel VM
|
||||||
@attribute [Authorize]
|
|
||||||
@inject ICmsTrackService CmsTrackService
|
@inject ICmsTrackService CmsTrackService
|
||||||
@inject IDialogService DialogService
|
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject ILogger<TrackList> Logger
|
@inject ILogger<TrackList> Logger
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudText Typo="Typo.h3" Class="mb-4">Tracks</MudText>
|
|
||||||
|
|
||||||
<MudTabs Elevation="0" Rounded="false" ApplyEffectsToContainer="true" PanelClass="pt-4">
|
|
||||||
<MudTabPanel Text="Tracks" Icon="@Icons.Material.Filled.LibraryMusic">
|
|
||||||
<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="20"
|
|
||||||
AllowUnsorted="false">
|
|
||||||
<NoRecordsContent>
|
|
||||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
|
||||||
</NoRecordsContent>
|
|
||||||
<LoadingContent>
|
|
||||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
|
||||||
</LoadingContent>
|
|
||||||
<HeaderContent>
|
|
||||||
<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>Entry Key</MudTh>
|
|
||||||
<MudTh>File Name</MudTh>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<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("yyyy-MM-dd") ?? "—")</MudTd>
|
|
||||||
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
|
|
||||||
<MudTd DataLabel="File Name"><MudText Typo="Typo.caption" Style="font-family: monospace;">@(context.OriginalFileName ?? "—")</MudText></MudTd>
|
|
||||||
<MudTd DataLabel="Actions">
|
|
||||||
<MudTooltip Text="Edit">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Primary"
|
|
||||||
Href="@($"/tracks/{context.Id}")" />
|
|
||||||
</MudTooltip>
|
|
||||||
<MudTooltip Text="Delete">
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Error"
|
|
||||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
|
||||||
</MudTooltip>
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
<PagerContent>
|
|
||||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
|
||||||
</PagerContent>
|
|
||||||
</MudTable>
|
|
||||||
</MudTabPanel>
|
|
||||||
|
|
||||||
<MudTabPanel Text="Waveform Pre-Processing" Icon="@Icons.Material.Filled.GraphicEq">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||||
<MudStack Spacing="0">
|
<MudText Typo="Typo.h3">Tracks</MudText>
|
||||||
<MudText Typo="Typo.h5">Waveform Pre-Processing</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">
|
@if (VM.Mode == BrowseMode.Tracks)
|
||||||
Generate loudness profiles for tracks that predate the waveform seeker.
|
{
|
||||||
</MudText>
|
<MudButton Variant="Variant.Outlined"
|
||||||
</MudStack>
|
|
||||||
<MudButton Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||||
Disabled="@(_bulkRunning || _missingCount == 0)"
|
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
||||||
OnClick="GenerateAllMissing">
|
OnClick="GenerateAllMissingAsync">
|
||||||
@if (_bulkRunning)
|
@if (_bulkRunning)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||||
@@ -99,184 +29,86 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>Generate All Missing (@_missingCount)</span>
|
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
|
||||||
}
|
}
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
<MudTable T="WaveformStatusDto"
|
<MudToggleGroup T="BrowseMode"
|
||||||
Items="_waveformRows"
|
Value="VM.Mode"
|
||||||
Loading="_waveformLoading"
|
ValueChanged="OnModeChanged"
|
||||||
Hover="true"
|
SelectionMode="SelectionMode.SingleSelection"
|
||||||
Striped="true"
|
|
||||||
Dense="true"
|
|
||||||
Bordered="false"
|
|
||||||
FixedHeader="true">
|
|
||||||
<NoRecordsContent>
|
|
||||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
|
||||||
</NoRecordsContent>
|
|
||||||
<LoadingContent>
|
|
||||||
<MudText Typo="Typo.body1">Loading waveform status…</MudText>
|
|
||||||
</LoadingContent>
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>Track Name</MudTh>
|
|
||||||
<MudTh>Entry Key</MudTh>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
|
||||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
|
||||||
<MudTd DataLabel="Entry Key">
|
|
||||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Profile">
|
|
||||||
@if (context.HasProfile)
|
|
||||||
{
|
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text"
|
|
||||||
Icon="@Icons.Material.Filled.CheckCircle">Stored</MudChip>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Text"
|
|
||||||
Icon="@Icons.Material.Filled.Cancel">Missing</MudChip>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Actions">
|
|
||||||
@if (!context.HasProfile)
|
|
||||||
{
|
|
||||||
<MudButton Variant="Variant.Outlined"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
Size="Size.Small"
|
||||||
Disabled="@(_bulkRunning || IsGenerating(context.EntryKey))"
|
Class="mb-4">
|
||||||
OnClick="@(() => GenerateOne(context))">
|
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
|
||||||
@if (IsGenerating(context.EntryKey))
|
<MudToggleItem Value="BrowseMode.Albums">Albums</MudToggleItem>
|
||||||
|
<MudToggleItem Value="BrowseMode.Genres">Genres</MudToggleItem>
|
||||||
|
</MudToggleGroup>
|
||||||
|
|
||||||
|
@if (VM.Mode == BrowseMode.Tracks)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
|
||||||
<span>Generating…</span>
|
}
|
||||||
|
else if (VM.Mode == BrowseMode.Albums)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">Album browser — coming in the next wave.</MudAlert>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>Generate</span>
|
<MudAlert Severity="Severity.Info" Class="mt-4">Genre browser — coming in the next wave.</MudAlert>
|
||||||
}
|
}
|
||||||
</MudButton>
|
|
||||||
}
|
|
||||||
</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
</MudTabPanel>
|
|
||||||
</MudTabs>
|
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
// Track list fields
|
private CmsTrackGrid? _grid;
|
||||||
private MudTable<TrackDto>? _table;
|
|
||||||
|
|
||||||
// Waveform fields
|
// Local state for the parent-owned "Generate All Missing" bulk run.
|
||||||
private List<WaveformStatusDto> _waveformRows = new();
|
|
||||||
private readonly HashSet<string> _generating = new();
|
|
||||||
private bool _waveformLoading = true;
|
|
||||||
private bool _bulkRunning;
|
private bool _bulkRunning;
|
||||||
private int _bulkTotal;
|
private int _bulkTotal;
|
||||||
private int _bulkDone;
|
private int _bulkDone;
|
||||||
|
|
||||||
private int _missingCount => _waveformRows.Count(r => !r.HasProfile);
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadWaveformStatus();
|
var uri = NavigationManager.Uri;
|
||||||
|
var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? BrowseMode.Albums
|
||||||
|
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? BrowseMode.Genres
|
||||||
|
: BrowseMode.Tracks;
|
||||||
|
await VM.SwitchModeAsync(initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Track list methods ──────────────────────────────────────────────────
|
private async Task OnModeChanged(BrowseMode mode)
|
||||||
|
|
||||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
await VM.SwitchModeAsync(mode);
|
||||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
var path = mode switch
|
||||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
|
||||||
|
|
||||||
var result = await CmsTrackService.GetPagedAsync(pageNumber, state.PageSize, sortColumn, sortDescending, cancellationToken);
|
|
||||||
|
|
||||||
if (!result.Success || result.Value is null)
|
|
||||||
{
|
{
|
||||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
BrowseMode.Albums => "/tracks/albums",
|
||||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
BrowseMode.Genres => "/tracks/genres",
|
||||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
_ => "/tracks"
|
||||||
}
|
|
||||||
|
|
||||||
var page = result.Value;
|
|
||||||
return new TableData<TrackDto>
|
|
||||||
{
|
|
||||||
Items = page.Items,
|
|
||||||
TotalItems = page.TotalCount
|
|
||||||
};
|
};
|
||||||
|
NavigationManager.NavigateTo(path, replace: true);
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ConfirmAndDelete(TrackDto track)
|
/// <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 confirmed = await DialogService.ShowMessageBox(
|
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
|
||||||
title: "Delete track",
|
if (!statusResult.Success || statusResult.Value is null)
|
||||||
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);
|
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
if (result.Success)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
|
||||||
if (_table is not null) await _table.ReloadServerData();
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Waveform pre-processing methods ────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task LoadWaveformStatus()
|
|
||||||
{
|
|
||||||
_waveformLoading = true;
|
|
||||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
|
||||||
_waveformLoading = false;
|
|
||||||
|
|
||||||
if (!result.Success || result.Value is null)
|
|
||||||
{
|
|
||||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
||||||
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
|
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
|
||||||
_waveformRows = new List<WaveformStatusDto>();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_waveformRows = result.Value.OrderBy(r => r.HasProfile).ThenBy(r => r.TrackName).ToList();
|
var missing = statusResult.Value.Where(s => !s.HasProfile).ToList();
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsGenerating(string entryKey) => _generating.Contains(entryKey);
|
|
||||||
|
|
||||||
private async Task GenerateOne(WaveformStatusDto row)
|
|
||||||
{
|
|
||||||
if (!await GenerateForRow(row))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Snackbar.Add($"Generated profile for '{row.TrackName}'.", Severity.Success);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GenerateAllMissing()
|
|
||||||
{
|
|
||||||
var missing = _waveformRows.Where(r => !r.HasProfile).ToList();
|
|
||||||
if (missing.Count == 0)
|
if (missing.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -285,14 +117,22 @@
|
|||||||
_bulkRunning = true;
|
_bulkRunning = true;
|
||||||
_bulkTotal = missing.Count;
|
_bulkTotal = missing.Count;
|
||||||
_bulkDone = 0;
|
_bulkDone = 0;
|
||||||
|
_grid?.SetBulkRunning(true);
|
||||||
var failures = 0;
|
var failures = 0;
|
||||||
|
|
||||||
// Sequential by design: one request at a time so a large backfill does not flood the API
|
foreach (var status in missing)
|
||||||
// with concurrent WAV decodes.
|
|
||||||
foreach (var row in missing)
|
|
||||||
{
|
{
|
||||||
if (!await GenerateForRow(row))
|
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++;
|
failures++;
|
||||||
}
|
}
|
||||||
_bulkDone++;
|
_bulkDone++;
|
||||||
@@ -300,6 +140,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_bulkRunning = false;
|
_bulkRunning = false;
|
||||||
|
_grid?.SetBulkRunning(false);
|
||||||
|
|
||||||
|
if (_grid is not null)
|
||||||
|
{
|
||||||
|
await _grid.RefreshWaveformStatusAsync();
|
||||||
|
}
|
||||||
|
|
||||||
var succeeded = missing.Count - failures;
|
var succeeded = missing.Count - failures;
|
||||||
if (failures == 0)
|
if (failures == 0)
|
||||||
@@ -311,45 +157,4 @@
|
|||||||
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Runs generation for a single row, flipping its status on success. Returns false on failure
|
|
||||||
/// (a snackbar is raised here for the per-row path; the bulk path aggregates a summary). Marks
|
|
||||||
/// the row busy for the duration so its button shows a spinner and stays disabled.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<bool> GenerateForRow(WaveformStatusDto row)
|
|
||||||
{
|
|
||||||
_generating.Add(row.EntryKey);
|
|
||||||
StateHasChanged();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(row.EntryKey);
|
|
||||||
if (result.Success)
|
|
||||||
{
|
|
||||||
row.HasProfile = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
||||||
if (!_bulkRunning)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Generate failed for '{row.TrackName}': {error}", Severity.Error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", row.EntryKey);
|
|
||||||
if (!_bulkRunning)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Generate failed for '{row.TrackName}' — please try again.", Severity.Error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_generating.Remove(row.EntryKey);
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ builder.Services.AddMudServices();
|
|||||||
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
|
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
|
||||||
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
|
||||||
|
namespace DeepDrftManager.Services;
|
||||||
|
|
||||||
|
/// <summary>The three browse dimensions for the /tracks page.</summary>
|
||||||
|
public enum BrowseMode
|
||||||
|
{
|
||||||
|
Tracks,
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Album mode.
|
||||||
|
public IReadOnlyList<ReleaseDto> Albums { get; private set; } = Array.Empty<ReleaseDto>();
|
||||||
|
public bool AlbumsLoading { get; private set; }
|
||||||
|
|
||||||
|
// 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 album or genre dataset on first entry. Collapses
|
||||||
|
/// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SwitchModeAsync(BrowseMode mode)
|
||||||
|
{
|
||||||
|
Mode = mode;
|
||||||
|
ExpandedGenre = null; // collapse on mode switch
|
||||||
|
|
||||||
|
if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading)
|
||||||
|
{
|
||||||
|
AlbumsLoading = true;
|
||||||
|
var result = await _trackService.GetReleasesAsync();
|
||||||
|
Albums = result.Success && result.Value is not null
|
||||||
|
? result.Value
|
||||||
|
: Array.Empty<ReleaseDto>();
|
||||||
|
AlbumsLoading = false;
|
||||||
|
}
|
||||||
|
else 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ public class CmsTrackService : ICmsTrackService
|
|||||||
|
|
||||||
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
||||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||||
|
string? album = null, string? genre = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||||
@@ -162,6 +163,14 @@ public class CmsTrackService : ICmsTrackService
|
|||||||
{
|
{
|
||||||
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
|
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
|
||||||
}
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(album))
|
||||||
|
{
|
||||||
|
query += $"&album={Uri.EscapeDataString(album)}";
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
|
{
|
||||||
|
query += $"&genre={Uri.EscapeDataString(genre)}";
|
||||||
|
}
|
||||||
|
|
||||||
HttpResponseMessage response;
|
HttpResponseMessage response;
|
||||||
try
|
try
|
||||||
@@ -542,7 +551,7 @@ public class CmsTrackService : ICmsTrackService
|
|||||||
{
|
{
|
||||||
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
|
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
|
||||||
// dedicated count endpoint is needed.
|
// dedicated count endpoint is needed.
|
||||||
var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct);
|
var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct: ct);
|
||||||
if (!paged.Success || paged.Value is null)
|
if (!paged.Success || paged.Value is null)
|
||||||
{
|
{
|
||||||
var error = paged.Messages.FirstOrDefault()?.Message ?? "Failed to load track count.";
|
var error = paged.Messages.FirstOrDefault()?.Message ?? "Failed to load track count.";
|
||||||
|
|||||||
@@ -41,10 +41,13 @@ public interface ICmsTrackService
|
|||||||
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
|
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>.
|
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>. Optional
|
||||||
|
/// <paramref name="album"/> and <paramref name="genre"/> filters narrow the result to a single
|
||||||
|
/// release title or genre; null leaves the dimension unfiltered.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
||||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||||
|
string? album = null, string? genre = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user