@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 Logger Edit Release — DeepDrft CMS Edit Release @if (_loading) { } else if (_loadError is { } loadError) { @loadError } else { @if (_existingImagePath is not null && _selectedImageFile is null) { Remove cover } @* 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. *@ @* 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. *@ @if (!string.IsNullOrEmpty(_errorMessage)) { @_errorMessage } Cancel @if (_saving) { Saving @_processedCount / @_tracks.Count… } else { Save Changes } } @code { // ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload. private const long MaxUploadBytes = 2_000_000_000L; // 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 _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 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(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 marshals back onto the renderer dispatcher. var lastPercent = -1; var progress = new Progress(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(); } } }