ca90302f21
On partial failure the old path deleted the original audio before confirming the new write succeeded. Now: load old extension, register new audio first (original untouched on failure), then clean up stale backing file only on success and only when extension changed.
668 lines
27 KiB
Plaintext
668 lines
27 KiB
Plaintext
@page "/tracks/album/{AlbumName}/edit"
|
|
@page "/tracks/{TrackId:long}/edit"
|
|
@using System.Security.Claims
|
|
@using DeepDrftManager.Services
|
|
@using DeepDrftModels.DTOs
|
|
@using DeepDrftModels.Enums
|
|
@using Microsoft.AspNetCore.Components.Forms
|
|
@attribute [Authorize]
|
|
|
|
@inject ICmsTrackService CmsTrackService
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject NavigationManager Navigation
|
|
@inject ISnackbar Snackbar
|
|
@inject IDialogService DialogService
|
|
@inject ILogger<BatchEdit> Logger
|
|
|
|
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
|
|
|
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
|
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
|
|
|
@if (_loading)
|
|
{
|
|
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
|
}
|
|
else if (_loadError is { } loadError)
|
|
{
|
|
<MudAlert Severity="Severity.Warning" Class="mt-4">@loadError</MudAlert>
|
|
}
|
|
else
|
|
{
|
|
<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"
|
|
ExistingImagePath="_existingImagePath"
|
|
Disabled="_saving" />
|
|
|
|
@if (_existingImagePath is not null && _selectedImageFile is null)
|
|
{
|
|
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-4">
|
|
<MudButton Variant="Variant.Text"
|
|
Color="Color.Error"
|
|
StartIcon="@Icons.Material.Filled.Delete"
|
|
Disabled="_saving"
|
|
OnClick="RemoveCover">
|
|
Remove cover
|
|
</MudButton>
|
|
</MudStack>
|
|
}
|
|
|
|
@* Session/Mix are single-track releases (§9.3): suppress the add-track affordance and keep the
|
|
list collapsed to one row — OnMediumChanged trims rows 2..n when the medium switches to a
|
|
single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@
|
|
<MudGrid>
|
|
<MudItem xs="12" md="5">
|
|
@* ExistingTrackCount counts edit-session persisted rows (Id.HasValue), not authoritative
|
|
live release count — acceptable because this gate only hides a UI control; the
|
|
TrySoftDeleteEmptyReleaseAsync backstop remains the authoritative guard. *@
|
|
<BatchTrackList Tracks="_tracks"
|
|
@bind-SelectedIndex="_selectedIndex"
|
|
Disabled="_saving"
|
|
AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
|
|
ExistingTrackCount="@_tracks.Count(t => t.Id.HasValue)"
|
|
OnWavFilesSelected="HandleWavFilesSelected"
|
|
OnMoveUp="MoveUp"
|
|
OnMoveDown="MoveDown"
|
|
OnRemove="RemoveRow"
|
|
OnReplaceFileSelected="HandleReplaceFileSelected" />
|
|
</MudItem>
|
|
|
|
<MudItem xs="12" md="7">
|
|
<MudPaper Class="pa-4" Elevation="2">
|
|
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
|
Disabled="_saving"
|
|
ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"
|
|
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
|
</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("/releases"))"
|
|
Disabled="_saving">
|
|
Cancel
|
|
</MudButton>
|
|
<MudButton Variant="Variant.Filled"
|
|
Color="Color.Primary"
|
|
OnClick="SaveAsync"
|
|
Disabled="@(_saving || _tracks.Count == 0)">
|
|
@if (_saving)
|
|
{
|
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
|
<text>Saving @_processedCount / @_tracks.Count…</text>
|
|
}
|
|
else
|
|
{
|
|
<text>Save Changes</text>
|
|
}
|
|
</MudButton>
|
|
</MudStack>
|
|
}
|
|
</MudContainer>
|
|
|
|
@code {
|
|
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
|
private const long MaxUploadBytes = 1_073_741_824L;
|
|
|
|
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
|
|
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
|
|
|
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
|
|
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
|
|
// the track they clicked rather than on the release with no row context. Null for the
|
|
// release-title route. The two routes are mutually exclusive — only one segment binds.
|
|
[Parameter] public long? TrackId { get; set; }
|
|
|
|
private List<BatchRowModel> _tracks = new();
|
|
private int _selectedIndex = -1;
|
|
private bool _loading = true;
|
|
private string? _loadError;
|
|
private bool _saving;
|
|
private int _processedCount;
|
|
private string? _errorMessage;
|
|
|
|
private IBrowserFile? _selectedImageFile;
|
|
private string? _imagePath;
|
|
private string? _existingImagePath;
|
|
private bool _clearExistingImage;
|
|
|
|
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;
|
|
|
|
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
|
|
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
|
|
// default server-side for a non-Cut medium.
|
|
//
|
|
// Switching to a single-track medium collapses any multi-track list to the first row so the
|
|
// single-track invariant (§9.3) holds before save — the same collapse BatchUpload.OnMediumChanged
|
|
// performs, reading the same MediumRules cardinality the upload service enforces. Dropping rows
|
|
// 2..n is an in-memory trim only; existing tracks are not deleted server-side (RemoveRow owns
|
|
// deletion), so the hidden rows simply fall out of this edit session.
|
|
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;
|
|
}
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
|
|
// then fall through to the shared release-load path below. The clicked track's id is held
|
|
// for row pre-selection once the list is built.
|
|
var albumName = AlbumName;
|
|
if (TrackId is { } trackId)
|
|
{
|
|
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
|
|
if (!trackResult.Success || trackResult.Value is not { } track)
|
|
{
|
|
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
|
|
_loading = false;
|
|
return;
|
|
}
|
|
|
|
albumName = track.Release?.Title;
|
|
if (string.IsNullOrEmpty(albumName))
|
|
{
|
|
_loadError = "This track has no parent release to edit.";
|
|
_loading = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// A single page of 100 covers the full release (albums are small — same assumption as
|
|
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
|
var result = await CmsTrackService.GetPagedAsync(
|
|
page: 1, pageSize: 100,
|
|
sortColumn: "TrackNumber", sortDescending: false,
|
|
album: albumName);
|
|
|
|
if (!result.Success || result.Value is null)
|
|
{
|
|
_loadError = result.Messages.FirstOrDefault()?.Message ?? "Failed to load release.";
|
|
_loading = false;
|
|
return;
|
|
}
|
|
|
|
var tracks = result.Value.Items.ToList();
|
|
if (tracks.Count == 0)
|
|
{
|
|
_loadError = $"No tracks found for release '{albumName}'.";
|
|
_loading = false;
|
|
return;
|
|
}
|
|
|
|
var release = tracks[0].Release;
|
|
_albumName = albumName;
|
|
_artist = release?.Artist ?? string.Empty;
|
|
_genre = release?.Genre ?? string.Empty;
|
|
_description = release?.Description ?? string.Empty;
|
|
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
|
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
|
_medium = release?.Medium ?? ReleaseMedium.Cut;
|
|
_existingImagePath = release?.ImagePath;
|
|
|
|
_tracks = tracks.Select(t => new BatchRowModel
|
|
{
|
|
Id = t.Id,
|
|
EntryKey = t.EntryKey,
|
|
OriginalFileName = t.OriginalFileName,
|
|
TrackName = t.TrackName,
|
|
TrackNumber = t.TrackNumber,
|
|
WavFile = null,
|
|
Status = BatchRowStatus.Queued
|
|
}).ToList();
|
|
|
|
// Same single-track collapse on the load path, via the shared MediumRules declaration: a
|
|
// release whose stored medium is single-track surfaces only its first row for editing.
|
|
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 1)
|
|
{
|
|
_tracks.RemoveRange(1, _tracks.Count - 1);
|
|
}
|
|
|
|
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
|
|
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
|
|
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
|
|
_selectedIndex = ResolveInitialSelection();
|
|
_loading = false;
|
|
}
|
|
|
|
private int ResolveInitialSelection()
|
|
{
|
|
if (_tracks.Count == 0) return -1;
|
|
if (TrackId is { } trackId)
|
|
{
|
|
var addressed = _tracks.FindIndex(t => t.Id == trackId);
|
|
if (addressed >= 0) return addressed;
|
|
}
|
|
return 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;
|
|
}
|
|
|
|
// New rows carry no Id — they take the upload path on save.
|
|
_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 async Task RemoveRow(int index)
|
|
{
|
|
var row = _tracks[index];
|
|
if (row.Id.HasValue)
|
|
{
|
|
// Existing track — confirm before deleting.
|
|
var confirmed = await DialogService.ShowMessageBox(
|
|
"Remove track",
|
|
$"Remove '{row.TrackName}' from this release? This deletes the track permanently.",
|
|
yesText: "Remove", cancelText: "Cancel");
|
|
if (confirmed != true) return;
|
|
|
|
var result = await CmsTrackService.DeleteTrackAsync(row.Id.Value);
|
|
if (!result.Success)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// New track (not yet uploaded) or confirmed existing delete — remove from list.
|
|
_tracks.RemoveAt(index);
|
|
if (index < _selectedIndex) _selectedIndex--;
|
|
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
|
}
|
|
|
|
private async Task HandleReplaceFileSelected((int Index, IBrowserFile File) picked)
|
|
{
|
|
var (index, file) = picked;
|
|
if (index < 0 || index >= _tracks.Count) return;
|
|
|
|
var row = _tracks[index];
|
|
if (!row.Id.HasValue)
|
|
{
|
|
// Defensive: replace is only offered on persisted rows. A new row would have no track to
|
|
// swap against — it takes the upload path on save instead.
|
|
return;
|
|
}
|
|
|
|
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
|
|
return;
|
|
}
|
|
|
|
var confirmed = await DialogService.ShowMessageBox(
|
|
"Replace audio",
|
|
$"Replace the audio for '{row.TrackName}' with '{file.Name}'? " +
|
|
"Metadata stays the same; the waveform is regenerated for the new audio.",
|
|
yesText: "Replace", cancelText: "Cancel");
|
|
if (confirmed != true) return;
|
|
|
|
row.Status = BatchRowStatus.Uploading;
|
|
row.UploadedBytes = 0;
|
|
row.TotalBytes = file.Size;
|
|
row.ErrorMessage = null;
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
await using var wavStream = file.OpenReadStream(MaxUploadBytes);
|
|
|
|
var lastPercent = -1;
|
|
var progress = new Progress<long>(written =>
|
|
{
|
|
row.UploadedBytes = written;
|
|
if (row.UploadPercent != lastPercent)
|
|
{
|
|
lastPercent = row.UploadPercent;
|
|
StateHasChanged();
|
|
}
|
|
});
|
|
|
|
var result = await CmsTrackService.ReplaceTrackAudioAsync(
|
|
row.Id.Value, wavStream, file.Size, file.Name, file.ContentType, progress);
|
|
|
|
if (!result.Success)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
row.Status = BatchRowStatus.Failed;
|
|
row.ErrorMessage = error;
|
|
Snackbar.Add($"Replace failed: {error}", Severity.Error);
|
|
}
|
|
else
|
|
{
|
|
// Reset to Queued (not Done): a Done row is skipped by SaveAsync, but the admin may
|
|
// still want to save pending metadata edits. The audio swap is already persisted.
|
|
row.Status = BatchRowStatus.Queued;
|
|
row.OriginalFileName = file.Name;
|
|
Snackbar.Add($"Replaced audio for '{row.TrackName}'.", Severity.Success);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Replace audio failed for track id {Id}", row.Id);
|
|
row.Status = BatchRowStatus.Failed;
|
|
row.ErrorMessage = "Replace failed — please try again.";
|
|
Snackbar.Add("Replace failed — please try again.", Severity.Error);
|
|
}
|
|
finally
|
|
{
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private void RemoveCover()
|
|
{
|
|
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
|
|
// the existing path here drops the preview in AlbumHeaderFields.
|
|
_clearExistingImage = true;
|
|
_existingImagePath = null;
|
|
}
|
|
|
|
private async Task SaveAsync()
|
|
{
|
|
_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 = "A release must have 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;
|
|
}
|
|
|
|
// New rows (no Id) need a WAV; existing rows keep their vault audio.
|
|
foreach (var t in _tracks)
|
|
{
|
|
if (!t.Id.HasValue && 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))
|
|
{
|
|
// [Authorize]/Admin-gated page — an unparseable id here is a configuration bug.
|
|
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
|
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
|
return;
|
|
}
|
|
|
|
DateOnly? releaseDate = string.IsNullOrWhiteSpace(_releaseDate)
|
|
? null
|
|
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
|
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
|
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
|
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
|
|
|
|
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
|
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
|
|
// carry through to the stored track name.
|
|
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
|
|
{
|
|
_tracks[0].TrackName = _albumName;
|
|
}
|
|
|
|
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
|
_saving = true;
|
|
_processedCount = 0;
|
|
|
|
try
|
|
{
|
|
// Upload any newly picked cover art once; abort if it fails so we never point metadata
|
|
// at an image that was never stored.
|
|
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;
|
|
}
|
|
|
|
// Tri-state cover for UpdateAsync: a freshly uploaded path sets it; an explicit clear
|
|
// sends ""; otherwise null leaves the existing cover untouched.
|
|
string? imagePathForUpdate =
|
|
_imagePath is { } newPath ? newPath
|
|
: _clearExistingImage ? ""
|
|
: null;
|
|
|
|
int succeeded = 0, failed = 0;
|
|
for (int i = 0; i < _tracks.Count; i++)
|
|
{
|
|
var row = _tracks[i];
|
|
|
|
if (row.Status == BatchRowStatus.Done)
|
|
{
|
|
_processedCount++;
|
|
continue;
|
|
}
|
|
|
|
var trackNumber = i + 1; // 1-based ordinal from list position
|
|
|
|
row.Status = BatchRowStatus.Uploading;
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
if (row.Id.HasValue)
|
|
{
|
|
// Existing track — metadata-only update; audio stays in the vault.
|
|
var updateResult = await CmsTrackService.UpdateAsync(
|
|
row.Id.Value,
|
|
row.TrackName,
|
|
_artist,
|
|
album,
|
|
genre,
|
|
description,
|
|
releaseDate,
|
|
imagePathForUpdate,
|
|
_releaseType,
|
|
_medium,
|
|
trackNumber);
|
|
|
|
if (!updateResult.Success)
|
|
{
|
|
var error = updateResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
row.Status = BatchRowStatus.Failed;
|
|
row.ErrorMessage = error;
|
|
failed++;
|
|
Logger.LogWarning("Batch edit: update for '{TrackName}' (id={Id}) failed: {Error}",
|
|
row.TrackName, row.Id.Value, error);
|
|
}
|
|
else
|
|
{
|
|
row.Status = BatchRowStatus.Done;
|
|
succeeded++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// New track — upload, then link cover art with a follow-up update (same
|
|
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
|
|
row.UploadedBytes = 0;
|
|
row.TotalBytes = row.WavFile!.Size;
|
|
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
|
|
|
|
// Re-render only on whole-percent change so a large upload paints ~100 frames,
|
|
// not thousands. Progress<T> marshals back onto the renderer dispatcher.
|
|
var lastPercent = -1;
|
|
var progress = new Progress<long>(written =>
|
|
{
|
|
row.UploadedBytes = written;
|
|
if (row.UploadPercent != lastPercent)
|
|
{
|
|
lastPercent = row.UploadPercent;
|
|
StateHasChanged();
|
|
}
|
|
});
|
|
|
|
var uploadResult = await CmsTrackService.UploadTrackAsync(
|
|
wavStream,
|
|
row.WavFile.Size,
|
|
row.WavFile.Name,
|
|
row.WavFile.ContentType,
|
|
row.TrackName,
|
|
_artist,
|
|
album,
|
|
genre,
|
|
description,
|
|
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
|
row.WavFile.Name,
|
|
createdByUserId,
|
|
_releaseType,
|
|
trackNumber,
|
|
_medium,
|
|
progress);
|
|
|
|
if (!uploadResult.Success || uploadResult.Value is null)
|
|
{
|
|
var error = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
row.Status = BatchRowStatus.Failed;
|
|
row.ErrorMessage = error;
|
|
failed++;
|
|
Logger.LogWarning("Batch edit: upload for new track '{TrackName}' failed: {Error}",
|
|
row.TrackName, error);
|
|
}
|
|
else
|
|
{
|
|
// Link a cover only when one is actively set ("" clear doesn't apply to
|
|
// a brand-new track that has no cover yet).
|
|
if (imagePathForUpdate is { Length: > 0 } linkPath)
|
|
{
|
|
var linkResult = await CmsTrackService.UpdateAsync(
|
|
uploadResult.Value.Id,
|
|
row.TrackName,
|
|
_artist,
|
|
album,
|
|
genre,
|
|
description,
|
|
releaseDate,
|
|
linkPath,
|
|
_releaseType,
|
|
_medium,
|
|
trackNumber);
|
|
|
|
if (!linkResult.Success)
|
|
{
|
|
// Non-blocking: track persisted; cover can be re-linked by re-editing.
|
|
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
|
row.TrackName, uploadResult.Value.Id);
|
|
}
|
|
}
|
|
|
|
row.Status = BatchRowStatus.Done;
|
|
succeeded++;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Batch edit: exception processing '{TrackName}'", row.TrackName);
|
|
row.Status = BatchRowStatus.Failed;
|
|
row.ErrorMessage = "Save failed — please try again.";
|
|
failed++;
|
|
}
|
|
|
|
_processedCount++;
|
|
StateHasChanged();
|
|
}
|
|
|
|
if (failed == 0)
|
|
{
|
|
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
|
Navigation.NavigateTo("/releases");
|
|
}
|
|
else
|
|
{
|
|
Snackbar.Add($"Saved {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
|
// Stay on page so the admin can see the failed rows.
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_saving = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
}
|