Files
deepdrft/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor
T
daniel-c-harvey 2bd9aa7b74 fix(cms): rename "Album Name" label to "Release Name" across release header form
Covers AlbumHeaderFields MudTextField label + RequiredError, and the matching
code-side validation messages in BatchEdit and BatchUpload for consistency.
2026-06-13 19:45:55 -04:00

385 lines
15 KiB
Plaintext

@page "/tracks/upload"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
@inject CmsTrackBrowserViewModel VM
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile"
Disabled="_uploading" />
@if (_medium == ReleaseMedium.Cut)
{
<MudGrid>
<MudItem xs="12" md="5">
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_uploading"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_uploading"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
</MudGrid>
}
else
{
@* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@
<MudPaper Class="pa-4" Elevation="2">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle1">Track</MudText>
<InputFile OnChange="HandleSingleWavSelected" accept=".wav,audio/wav,audio/x-wav" disabled="@_uploading" />
@if (_tracks.Count > 0)
{
<MudTextField @bind-Value="_tracks[0].TrackName"
Label="Track Name"
Variant="Variant.Outlined"
Disabled="_uploading" />
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
}
</MudStack>
</MudPaper>
}
@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 List<BatchRowModel> _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 ReleaseMedium _medium = ReleaseMedium.Cut;
// Switching to a single-track medium collapses any multi-track selection to the first row so the
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
// declaration the upload service enforces, so the form and the domain cannot drift.
private void OnMediumChanged(ReleaseMedium medium)
{
_medium = medium;
if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
}
}
// Single-track WAV picker for Session/Mix: replaces the one row rather than appending.
private void HandleSingleWavSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
var file = e.File;
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
return;
}
_tracks.Clear();
_tracks.Add(new BatchRowModel
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
_selectedIndex = 0;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
_errorMessage = null;
foreach (var file in files)
{
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
continue;
}
_tracks.Add(new BatchRowModel
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
}
if (_selectedIndex < 0 && _tracks.Count > 0)
{
_selectedIndex = 0;
}
}
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 async Task SubmitAsync()
{
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Release 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;
}
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
_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 = BatchRowStatus.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,
_medium);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
row.Status = BatchRowStatus.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,
_medium,
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);
}
}
// Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS
// computes nothing — the API derives the datum from the audio it just stored.
// Non-blocking: the track is persisted; a failed trigger is recoverable from
// the Mixes browser's per-row Generate action.
if (_medium == ReleaseMedium.Mix && result.Value.ReleaseId is { } mixReleaseId)
{
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
if (!waveformResult.Success)
{
Logger.LogWarning("Batch upload: mix waveform trigger failed for release {ReleaseId} ('{TrackName}')",
mixReleaseId, row.TrackName);
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
}
}
row.Status = BatchRowStatus.Done;
succeeded++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = "Upload failed — please try again.";
failed++;
}
_uploadedCount++;
StateHasChanged();
}
if (failed == 0)
{
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
VM.Invalidate();
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();
}
}
}