cfacc9f79a
New nullable Description column (max 4000) on ReleaseEntity, rides the Genre write channel through upload + edit; multiline CMS input. Migration authored, not applied.
572 lines
23 KiB
Plaintext
572 lines
23 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 CmsTrackBrowserViewModel VM
|
|
@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">
|
|
<BatchTrackList Tracks="_tracks"
|
|
@bind-SelectedIndex="_selectedIndex"
|
|
Disabled="_saving"
|
|
AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
|
|
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="_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("/tracks/albums"))"
|
|
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 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).
|
|
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
|
var uploadResult = await CmsTrackService.UploadTrackAsync(
|
|
wavStream,
|
|
row.WavFile.Name,
|
|
row.WavFile.ContentType,
|
|
row.TrackName,
|
|
_artist,
|
|
album,
|
|
genre,
|
|
description,
|
|
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
|
row.WavFile.Name,
|
|
createdByUserId,
|
|
_releaseType,
|
|
trackNumber,
|
|
_medium);
|
|
|
|
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();
|
|
}
|
|
|
|
// Either branch changed catalogue data, so the browse caches are stale regardless of
|
|
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
|
|
// album and genre lists re-fetch.
|
|
VM.Invalidate();
|
|
|
|
if (failed == 0)
|
|
{
|
|
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
|
Navigation.NavigateTo("/tracks/albums");
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
}
|