feat(cms): add batch upload page for multi-track releases at /tracks/upload

This commit is contained in:
daniel-c-harvey
2026-06-10 21:43:31 -04:00
parent 480c961a09
commit 72171c9374
2 changed files with 470 additions and 1 deletions
@@ -0,0 +1,469 @@
@page "/tracks/upload"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
<MudPaper Class="pa-6 mb-4" Elevation="2">
<MudGrid>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_albumName" Label="Album Name" Required="true" RequiredError="Album Name is required" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="ReleaseType" @bind-Value="_releaseType" Label="Release Type" Variant="Variant.Outlined">
@foreach (var rt in Enum.GetValues<ReleaseType>())
{
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (_selectedImageFile is { } selectedImage)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_uploading"
OnClick="ClearImage"
aria-label="Cancel image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
}
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@_uploading" />
@if (_selectedImageFile is not null)
{
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
}
</MudStack>
</MudField>
</MudItem>
</MudGrid>
</MudPaper>
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" GutterBottom="true">Tracks</MudText>
<InputFile OnChange="HandleWavFilesSelected" accept=".wav,audio/wav,audio/x-wav" multiple disabled="@_uploading" />
@if (_tracks.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Default" Class="mt-3">No tracks added yet.</MudText>
}
else
{
<MudList T="BatchTrackRow" Class="mt-3">
@for (var i = 0; i < _tracks.Count; i++)
{
var index = i;
var row = _tracks[index];
<div style="@RowStyle(index)" @onclick="() => SelectRow(index)">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="pa-2">
<MudText Typo="Typo.body2" Style="min-width: 1.5rem;">@(index + 1).</MudText>
<MudText Typo="Typo.body2" Style="flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@row.TrackName</MudText>
@StatusChip(row)
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
Size="Size.Small"
Disabled="@(index == 0 || _uploading)"
OnClick="@(() => MoveUp(index))"
aria-label="Move track up" />
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
Size="Size.Small"
Disabled="@(index == _tracks.Count - 1 || _uploading)"
OnClick="@(() => MoveDown(index))"
aria-label="Move track down" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
Disabled="@_uploading"
OnClick="@(() => RemoveRow(index))"
aria-label="Remove track" />
</MudStack>
</div>
}
</MudList>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
@if (_selectedIndex < 0 || _tracks.Count == 0)
{
<MudText Typo="Typo.body1" Color="Color.Default">Select a track from the list to edit its details.</MudText>
}
else
{
var selected = _tracks[_selectedIndex];
<MudStack Spacing="4">
<MudTextField @bind-Value="selected.TrackName"
Label="Track Name"
Required="true"
RequiredError="Track Name is required"
Variant="Variant.Outlined"
Disabled="_uploading" />
<MudField Label="WAV File" Variant="Variant.Outlined" InnerPadding="false">
@if (selected.WavFile is { } wav)
{
<MudText Typo="Typo.body2">@wav.Name (@FormatBytes(wav.Size))</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Error">No WAV file selected.</MudText>
}
</MudField>
@if (selected.Status == TrackUploadStatus.Failed)
{
<MudAlert Severity="Severity.Error">@selected.ErrorMessage</MudAlert>
}
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
}
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
Disabled="_uploading">
Cancel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SubmitAsync"
Disabled="@(_uploading || _tracks.Count == 0)">
@if (_uploading)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
<text>Uploading @_uploadedCount / @_tracks.Count…</text>
}
else
{
<text>Upload Release</text>
}
</MudButton>
</MudStack>
</MudContainer>
@code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
// streaming path means the limit caps the request, not in-memory buffering.
private const long MaxUploadBytes = 1_073_741_824L;
private const int MaxFilesPerPick = 50;
private List<BatchTrackRow> _tracks = new();
private int _selectedIndex = -1;
private bool _uploading;
private int _uploadedCount;
private string? _errorMessage;
private IBrowserFile? _selectedImageFile;
private string? _imagePath;
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private class BatchTrackRow
{
public IBrowserFile? WavFile { get; set; }
public string TrackName { get; set; } = string.Empty;
public TrackUploadStatus Status { get; set; } = TrackUploadStatus.Queued;
public string? ErrorMessage { get; set; }
}
private enum TrackUploadStatus { Queued, Uploading, Done, Failed }
private void HandleWavFilesSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
foreach (var file in e.GetMultipleFiles(MaxFilesPerPick))
{
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
continue;
}
_tracks.Add(new BatchTrackRow
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
}
if (_selectedIndex < 0 && _tracks.Count > 0)
{
_selectedIndex = 0;
}
}
private void SelectRow(int index) => _selectedIndex = index;
private void MoveUp(int i)
{
if (i == 0) return;
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
if (_selectedIndex == i) _selectedIndex = i - 1;
else if (_selectedIndex == i - 1) _selectedIndex = i;
}
private void MoveDown(int i)
{
if (i == _tracks.Count - 1) return;
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
if (_selectedIndex == i) _selectedIndex = i + 1;
else if (_selectedIndex == i + 1) _selectedIndex = i;
}
private void RemoveRow(int i)
{
_tracks.RemoveAt(i);
if (i < _selectedIndex) _selectedIndex--;
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
}
private string RowStyle(int index)
{
var baseStyle = "cursor: pointer; border-radius: 4px;";
return index == _selectedIndex
? $"{baseStyle} background-color: var(--mud-palette-action-default-hover);"
: baseStyle;
}
private RenderFragment StatusChip(BatchTrackRow row) => row.Status switch
{
TrackUploadStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
TrackUploadStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
TrackUploadStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
};
private void HandleImageFileSelected(InputFileChangeEventArgs e)
{
_selectedImageFile = e.File;
_imagePath = null;
}
private void ClearImage()
{
_selectedImageFile = null;
_imagePath = null;
}
private async Task SubmitAsync()
{
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Album Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_artist))
{
_errorMessage = "Artist is required.";
return;
}
if (_tracks.Count == 0)
{
_errorMessage = "Add at least one track.";
return;
}
if (!string.IsNullOrWhiteSpace(_releaseDate)
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
{
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
return;
}
foreach (var t in _tracks)
{
if (t.WavFile is null)
{
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
return;
}
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(userIdValue, out var createdByUserId))
{
// The page is gated by [Authorize] under the Admin role, so a missing or
// unparseable id here is a configuration bug, not normal client state.
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
return;
}
_uploading = true;
_uploadedCount = 0;
try
{
// Upload any selected cover art once; abort the submit if it fails so we never
// create tracks expecting an image that was never stored in the vault.
if (_selectedImageFile is { } imgFile)
{
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
if (!imgResult.Success)
{
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Image upload failed: {imgError}";
return;
}
_imagePath = imgResult.Value;
}
int succeeded = 0, failed = 0;
for (int i = 0; i < _tracks.Count; i++)
{
var row = _tracks[i];
var trackNumber = i + 1; // 1-based ordinal from list position
row.Status = TrackUploadStatus.Uploading;
StateHasChanged();
try
{
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
// service wraps it in StreamContent so the whole file is never materialised in
// memory before DeepDrftAPI receives it.
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
var result = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
row.Status = TrackUploadStatus.Failed;
row.ErrorMessage = error;
failed++;
Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error);
}
else
{
// The upload endpoint does not accept an imagePath, so link the cover art with
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
if (_imagePath is { } imgPath)
{
var linkResult = await CmsTrackService.UpdateAsync(
result.Value.Id,
row.TrackName,
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
trackNumber);
if (!linkResult.Success)
{
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
row.TrackName, result.Value.Id);
}
}
row.Status = TrackUploadStatus.Done;
succeeded++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
row.Status = TrackUploadStatus.Failed;
row.ErrorMessage = "Upload failed — please try again.";
failed++;
}
_uploadedCount++;
StateHasChanged();
}
if (failed == 0)
{
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
Navigation.NavigateTo("/tracks");
}
else
{
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
// Stay on page so the admin can see the failed rows.
}
}
finally
{
_uploading = false;
StateHasChanged();
}
}
private static string FormatBytes(long bytes)
{
const long KB = 1024;
const long MB = KB * 1024;
const long GB = MB * 1024;
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
return $"{bytes} bytes";
}
}
@@ -19,7 +19,7 @@
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/new">
Href="/tracks/upload">
Add Track
</MudButton>
</MudStack>