From 72171c9374fe8cab9778f1209f9aa447c33717b7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 10 Jun 2026 21:43:31 -0400 Subject: [PATCH] feat(cms): add batch upload page for multi-track releases at /tracks/upload --- .../Components/Pages/Tracks/BatchUpload.razor | 469 ++++++++++++++++++ .../Components/Pages/Tracks/TrackList.razor | 2 +- 2 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor new file mode 100644 index 0000000..a82370f --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -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 Logger + +Upload Release — DeepDrft CMS + + + Upload Release + + + + + + + + + + + + + + + + + + @foreach (var rt in Enum.GetValues()) + { + @rt + } + + + + + + @if (_selectedImageFile is { } selectedImage) + { + + Selected: @selectedImage.Name + + + } + else + { + No cover art — optional. + } + + + @if (_selectedImageFile is not null) + { + Will upload on submit. + } + + + + + + + + + + Tracks + + + + @if (_tracks.Count == 0) + { + No tracks added yet. + } + else + { + + @for (var i = 0; i < _tracks.Count; i++) + { + var index = i; + var row = _tracks[index]; +
+ + @(index + 1). + @row.TrackName + @StatusChip(row) + + + + +
+ } +
+ } +
+
+ + + + @if (_selectedIndex < 0 || _tracks.Count == 0) + { + Select a track from the list to edit its details. + } + else + { + var selected = _tracks[_selectedIndex]; + + + + + @if (selected.WavFile is { } wav) + { + @wav.Name (@FormatBytes(wav.Size)) + } + else + { + No WAV file selected. + } + + + @if (selected.Status == TrackUploadStatus.Failed) + { + @selected.ErrorMessage + } + + } + + +
+ + @if (!string.IsNullOrEmpty(_errorMessage)) + { + @_errorMessage + } + + + + Cancel + + + @if (_uploading) + { + + Uploading @_uploadedCount / @_tracks.Count… + } + else + { + Upload Release + } + + +
+ +@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 _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 => @ + Uploading, + TrackUploadStatus.Done => @Done, + TrackUploadStatus.Failed => @Failed, + _ => @Queued + }; + + 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"; + } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index aacde5e..7505d40 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -19,7 +19,7 @@ + Href="/tracks/upload"> Add Track