@page "/tracks/album/{AlbumName}/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 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 } @* The medium write path persists _medium on save (9.5.B). BatchEdit still shows the full multi-track list for every medium; collapsing to a single-track slot for Session/Mix (matching BatchUpload's @if (_medium == ReleaseMedium.Cut) guard) is deferred form-shape work, not part of the write-path wiring. *@ @if (!string.IsNullOrEmpty(_errorMessage)) { @_errorMessage } Cancel @if (_saving) { Saving @_processedCount / @_tracks.Count… } else { Save Changes } } @code { // 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload. private const long MaxUploadBytes = 1_073_741_824L; [Parameter] public string AlbumName { get; set; } = string.Empty; 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 _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. private void OnMediumChanged(ReleaseMedium medium) => _medium = medium; protected override async Task OnInitializedAsync() { // 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; _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(); _selectedIndex = _tracks.Count > 0 ? 0 : -1; _loading = false; } 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 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 = "Album 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; _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, 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, 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, releaseDate, linkPath, _releaseType, _medium, trackNumber); if (!linkResult.Success) { // Non-blocking: track persisted; cover can be linked via TrackEdit. 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(); } } }