545 lines
26 KiB
Plaintext
545 lines
26 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
|
|
|
|
<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-Description="_description"
|
|
@bind-ReleaseDate="_releaseDate"
|
|
@bind-ReleaseType="_releaseType"
|
|
Medium="_medium"
|
|
MediumChanged="OnMediumChanged"
|
|
@bind-SelectedImageFile="_selectedImageFile"
|
|
@bind-HeroImageFile="_heroImageFile"
|
|
AllowHeroUpload="true"
|
|
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)
|
|
{
|
|
@* Track name is derived from the Release Name for Session/Mix — no separate input. *@
|
|
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
|
|
@if (_tracks[0].Status == BatchRowStatus.Uploading)
|
|
{
|
|
<MudProgressLinear Color="Color.Info"
|
|
Value="@_tracks[0].UploadPercent"
|
|
aria-label="Uploading track" />
|
|
}
|
|
}
|
|
</MudStack>
|
|
</MudPaper>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
|
{
|
|
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(_warningMessage))
|
|
{
|
|
<MudAlert Severity="Severity.Warning" Class="mt-4">@_warningMessage</MudAlert>
|
|
}
|
|
|
|
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
|
<MudButton Variant="Variant.Text"
|
|
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
|
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.86 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 = 2_000_000_000L;
|
|
|
|
private List<BatchRowModel> _tracks = new();
|
|
private int _selectedIndex = -1;
|
|
private bool _uploading;
|
|
private int _uploadedCount;
|
|
private string? _errorMessage;
|
|
// Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure.
|
|
private string? _warningMessage;
|
|
|
|
private IBrowserFile? _selectedImageFile;
|
|
private string? _imagePath;
|
|
|
|
// Session-only: the hero image is resource-addressed and cannot be uploaded until the release
|
|
// exists, so it is held here and POSTed to api/release/{id}/session/hero-image after create.
|
|
private IBrowserFile? _heroImageFile;
|
|
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
|
|
private bool _heroWarningAcknowledged;
|
|
|
|
private string _albumName = string.Empty;
|
|
private string _artist = string.Empty;
|
|
private string _genre = string.Empty;
|
|
private string _description = string.Empty;
|
|
private string _releaseDate = string.Empty;
|
|
private ReleaseType _releaseType = ReleaseType.Single;
|
|
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
|
|
|
// Optional pre-select from the Add-Track buttons (§8.E): /tracks/upload?medium=session lands the
|
|
// form already in Session mode. A seed only — the medium selector stays user-changeable after load.
|
|
// Unrecognised/absent values fall through to the Cut default (same defensive posture as the API's
|
|
// TrackController.UploadTrack medium parse).
|
|
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
// Seed the medium from the query param so a pre-selected upload form (e.g. the Sessions tab's
|
|
// Add Track) lands already showing that medium's conditional fields. Goes through OnMediumChanged
|
|
// so the single-track collapse runs identically to a user selector change.
|
|
if (!string.IsNullOrWhiteSpace(MediumParam)
|
|
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
|
&& Enum.IsDefined(medium))
|
|
{
|
|
OnMediumChanged(medium);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
_warningMessage = 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;
|
|
}
|
|
|
|
// A Session's hero is its primary visual identity on the public detail page. It is optional —
|
|
// a Session can be authored without one and set later from the Sessions browser — but a missing
|
|
// hero is usually an oversight, so warn (do not block). The first submit without a hero shows the
|
|
// warning and primes acknowledgment; a second submit proceeds.
|
|
if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged)
|
|
{
|
|
_heroWarningAcknowledged = true;
|
|
_warningMessage = "No hero image selected. A Session usually needs one — you can add it now, "
|
|
+ "or submit again to create the Session without it (set the hero later from the Sessions browser).";
|
|
return;
|
|
}
|
|
|
|
// Pre-flight duplicate guard (primary block): the upload form creates new releases only, so a
|
|
// (title, artist) that already exists in the catalogue is refused BEFORE any bytes transfer —
|
|
// the admin is not surprised at the end of a long upload. The server backstops this on the
|
|
// create path, but checking here keeps the failure fast and visible. The values passed match
|
|
// exactly what the upload sends (untrimmed _albumName/_artist) so the pre-flight and the server
|
|
// agree on the match. A check failure (API unreachable) blocks rather than proceeding blind.
|
|
var duplicateCheck = await CmsTrackService.GetExistingReleaseAsync(_albumName, _artist);
|
|
if (!duplicateCheck.Success)
|
|
{
|
|
var checkError = duplicateCheck.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_errorMessage = $"Could not verify the release name: {checkError}";
|
|
Snackbar.Add(_errorMessage, Severity.Error);
|
|
return;
|
|
}
|
|
|
|
if (duplicateCheck.Value is { } existing)
|
|
{
|
|
_errorMessage = $"A release titled '{existing.Title}' by {existing.Artist} already exists. "
|
|
+ "The upload form creates new releases only — use the edit tools to change an existing one.";
|
|
Snackbar.Add(_errorMessage, Severity.Error);
|
|
return;
|
|
}
|
|
|
|
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
|
// no separate Track Name input is shown. Sync here so the stored name always matches.
|
|
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
|
|
{
|
|
_tracks[0].TrackName = _albumName;
|
|
}
|
|
|
|
_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;
|
|
// Within-batch attach: row 1 creates the release (no releaseId → CREATE path); once it
|
|
// succeeds we carry its ReleaseId into rows 2..N so they ATTACH to the just-created release
|
|
// rather than tripping the server's pre-existing-duplicate block. Only a multi-track Cut
|
|
// reaches row 2 (single-track media collapse to one row).
|
|
long? batchReleaseId = null;
|
|
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();
|
|
|
|
row.UploadedBytes = 0;
|
|
row.TotalBytes = row.WavFile!.Size;
|
|
|
|
try
|
|
{
|
|
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
|
// service wraps it in ProgressStreamContent so the whole file is never materialised
|
|
// in memory before DeepDrftAPI receives it, and reports bytes-on-the-wire back here.
|
|
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
|
|
|
|
// Progress ticks fire ~once per 80 KB; re-render only when the whole-percent changes
|
|
// so a half-gig upload paints ~100 frames, not thousands. Progress<T> marshals the
|
|
// callback onto the component's renderer dispatcher, so StateHasChanged is safe here.
|
|
var lastPercent = -1;
|
|
var progress = new Progress<long>(written =>
|
|
{
|
|
row.UploadedBytes = written;
|
|
if (row.UploadPercent != lastPercent)
|
|
{
|
|
lastPercent = row.UploadPercent;
|
|
StateHasChanged();
|
|
}
|
|
});
|
|
|
|
var result = await CmsTrackService.UploadTrackAsync(
|
|
wavStream,
|
|
row.WavFile.Size,
|
|
row.WavFile.Name,
|
|
row.WavFile.ContentType,
|
|
row.TrackName,
|
|
_artist,
|
|
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
|
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
|
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
|
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
|
row.WavFile.Name,
|
|
createdByUserId,
|
|
_releaseType,
|
|
trackNumber,
|
|
_medium,
|
|
batchReleaseId,
|
|
progress);
|
|
|
|
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
|
|
{
|
|
// Capture the release id created by the first successful row so subsequent rows
|
|
// attach to it (the within-batch multi-track Cut path). Only set once — later
|
|
// rows must not overwrite it. A null ReleaseId here (loose track) leaves it null,
|
|
// which is correct: a release-less upload has no within-batch release to attach to.
|
|
if (batchReleaseId is null && result.Value.ReleaseId is { } createdReleaseId)
|
|
{
|
|
batchReleaseId = createdReleaseId;
|
|
}
|
|
|
|
// The upload endpoint does not accept an imagePath, so link the cover art with
|
|
// a follow-up metadata update — same two-step pattern BatchEdit uses.
|
|
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(_description) ? null : _description,
|
|
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 re-linked by editing.
|
|
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
|
row.TrackName, result.Value.Id);
|
|
}
|
|
}
|
|
|
|
// Session hero image is resource-addressed, so it is uploaded here — after the
|
|
// release exists and we have its id — within the same submit gesture. Non-blocking:
|
|
// the Session is persisted; a failed hero upload is recoverable from the Sessions
|
|
// browser's per-row Set/Replace hero action.
|
|
if (_medium == ReleaseMedium.Session
|
|
&& _heroImageFile is { } heroFile
|
|
&& result.Value.ReleaseId is { } sessionReleaseId)
|
|
{
|
|
try
|
|
{
|
|
await using var heroStream = heroFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
|
var heroResult = await CmsReleaseService.UploadSessionHeroImageAsync(
|
|
sessionReleaseId, heroStream, heroFile.Name, heroFile.ContentType);
|
|
if (!heroResult.Success)
|
|
{
|
|
var heroError = heroResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
Logger.LogWarning("Batch upload: hero image upload failed for release {ReleaseId} ('{TrackName}'): {Error}",
|
|
sessionReleaseId, row.TrackName, heroError);
|
|
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
|
|
}
|
|
}
|
|
catch (Exception heroEx)
|
|
{
|
|
Logger.LogError(heroEx, "Batch upload: exception uploading hero image for release {ReleaseId}", sessionReleaseId);
|
|
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
|
|
}
|
|
}
|
|
else if (_medium == ReleaseMedium.Session && _heroImageFile is not null)
|
|
{
|
|
// ReleaseId was null on a Session track result — internal inconsistency.
|
|
// Hero file is held but cannot be uploaded without a release id; log and
|
|
// surface so the admin can set it from the Sessions browser.
|
|
Logger.LogWarning("Batch upload: Session track '{TrackName}' (id={Id}) has no ReleaseId — hero image dropped",
|
|
row.TrackName, result.Value.Id);
|
|
Snackbar.Add("Session uploaded, but the hero image could not be linked (no release id). Set it from the Sessions browser.", Severity.Warning);
|
|
}
|
|
|
|
// 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);
|
|
Navigation.NavigateTo("/releases");
|
|
}
|
|
else
|
|
{
|
|
// Surface the actual reason, not just counts — a server rejection (duplicate, cardinality)
|
|
// relays a human-readable message via row.ErrorMessage. Show the first failure's reason so
|
|
// the admin sees WHY without scanning the rows; the per-row errors remain as detail.
|
|
var firstError = _tracks.FirstOrDefault(t => t.Status == BatchRowStatus.Failed)?.ErrorMessage;
|
|
var reason = string.IsNullOrWhiteSpace(firstError) ? "review errors below" : firstError;
|
|
_errorMessage = succeeded == 0 ? reason : $"{succeeded} uploaded; {failed} failed: {reason}";
|
|
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — {reason}", Severity.Warning);
|
|
// Stay on page so the admin can see the failed rows.
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_uploading = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
}
|