diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index 09b1609..9e80a5f 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -1,4 +1,5 @@ @page "/tracks/album/{AlbumName}/edit" +@page "/tracks/{TrackId:long}/edit" @using System.Security.Claims @using DeepDrftManager.Services @using DeepDrftModels.DTOs @@ -111,8 +112,15 @@ // 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 _tracks = new(); private int _selectedIndex = -1; private bool _loading = true; @@ -154,12 +162,35 @@ 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); + album: albumName); if (!result.Success || result.Value is null) { @@ -171,13 +202,13 @@ var tracks = result.Value.Items.ToList(); if (tracks.Count == 0) { - _loadError = $"No tracks found for release '{AlbumName}'."; + _loadError = $"No tracks found for release '{albumName}'."; _loading = false; return; } var release = tracks[0].Release; - _albumName = AlbumName; + _albumName = albumName; _artist = release?.Artist ?? string.Empty; _genre = release?.Genre ?? string.Empty; _releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty; @@ -203,10 +234,24 @@ _tracks.RemoveRange(1, _tracks.Count - 1); } - _selectedIndex = _tracks.Count > 0 ? 0 : -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; @@ -471,7 +516,7 @@ if (!linkResult.Success) { - // Non-blocking: track persisted; cover can be linked via TrackEdit. + // 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); } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index f1214d5..e3e6b70 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -360,7 +360,7 @@ else { // The upload endpoint does not accept an imagePath, so link the cover art with - // a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use. + // a follow-up metadata update — same two-step pattern BatchEdit uses. if (_imagePath is { } imgPath) { var linkResult = await CmsTrackService.UpdateAsync( @@ -377,7 +377,7 @@ if (!linkResult.Success) { - // Non-blocking: track is persisted; cover art can be linked via TrackEdit. + // 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); } diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor index 61c9e28..212e936 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor @@ -79,7 +79,7 @@ + Href="@($"/tracks/{context.Id}/edit")" /> Logger +@inject NavigationManager Navigation -Edit Track — DeepDrft CMS - - - - Back to tracks - - - @if (_loading) - { - - } - else if (_track is null) - { - - Track not found. - - } - else - { - Edit Track - - - - - @_track.EntryKey - - Vault reference — not editable. - - - - - - - - - - - - - - - - - - @if (ImagePreviewUrl is { } previewUrl) - { - - - - - } - else if (_selectedImageFile is not null) - { - - New image selected (not yet saved). - - - } - else - { - No cover art set. - } - - - @if (_selectedImageFile is { } selected) - { - Selected: @selected.Name (will upload on save) - } - - - - - - - - Delete - - - - Save Changes - - - - - } - +@* §8.M: the legacy single-track edit form is retired. Its edit responsibility is absorbed by + BatchEdit's track-addressed entry (/tracks/{id}/edit), which loads the parent release and + pre-selects the addressed track's row. This route is kept only as a redirect so any bookmarked + /tracks/{id} lands on the live edit surface; CmsTrackGrid's per-row Edit targets the new route + directly. *@ @code { [Parameter] public long Id { get; set; } - private TrackDto? _track; - private TrackEditForm _form = new(); - private bool _loading = true; - private bool _busy; - private IBrowserFile? _selectedImageFile; - - private bool CanSave => - !string.IsNullOrWhiteSpace(_form.TrackName) - && !string.IsNullOrWhiteSpace(_form.Artist); - - // Relative path — resolves against the Manager's own origin, proxied by ImageProxyController. - private string? ImagePreviewUrl => - string.IsNullOrEmpty(_form.ImagePath) - ? null - : $"/api/image/{Uri.EscapeDataString(_form.ImagePath)}"; - - protected override async Task OnInitializedAsync() - { - await LoadAsync(); - } - - private async Task LoadAsync() - { - _loading = true; - var result = await CmsTrackService.GetByIdAsync(Id); - _track = result.Success ? result.Value : null; - if (_track is not null) - { - _form = TrackEditForm.From(_track); - } - _loading = false; - } - - private async Task SaveAsync() - { - if (_track is null || !CanSave) return; - - _busy = true; - try - { - // Upload any newly picked cover art first; abort the save if it fails so we never - // persist metadata pointing at an image that was never stored. - if (_selectedImageFile is { } file) - { - await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000); - var uploadResult = await CmsTrackService.UploadImageAsync( - imageStream, file.Name, file.ContentType); - if (!uploadResult.Success) - { - var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error); - return; - } - _form.ImagePath = uploadResult.Value; - _selectedImageFile = null; - } - - // Metadata update over HTTP — EntryKey is immutable and not sent. The Content API - // loads the authoritative row and applies these fields. imagePath is tri-state: an - // explicit empty string clears the link, a value sets it. - var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null; - var updated = await CmsTrackService.UpdateAsync( - Id, _form.TrackName, _form.Artist, - string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album, - string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre, - releaseDate, - string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath, - _form.ReleaseType, - _form.Medium, - _form.TrackNumber); - if (updated.Success) - { - // Album/genre browse lists derive from this track's metadata; drop their cache so - // the /tracks browser re-fetches fresh data on next mode switch. - VM.Invalidate(); - Snackbar.Add("Track updated.", Severity.Success); - await LoadAsync(); - } - else - { - var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - Snackbar.Add($"Save failed: {error}", Severity.Error); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Save failed for track {TrackId}", Id); - Snackbar.Add("Save failed — please try again.", Severity.Error); - } - finally - { - _busy = false; - } - } - - private void HandleImageFileSelected(InputFileChangeEventArgs e) - { - _selectedImageFile = e.File; - } - - private void ClearImage() - { - _form.ImagePath = null; - _selectedImageFile = null; - } - - private async Task ConfirmDelete() - { - if (_track is null) return; - - var confirmed = await DialogService.ShowMessageBox( - "Delete track", - $"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.", - yesText: "Delete", - cancelText: "Cancel"); - - if (confirmed != true) return; - - _busy = true; - try - { - var result = await CmsTrackService.DeleteTrackAsync(Id); - if (result.Success) - { - // Deleting a track can empty or alter a release; drop the browse cache so the - // /tracks album and genre lists re-fetch fresh counts on next mode switch. - VM.Invalidate(); - Snackbar.Add("Track deleted.", Severity.Success); - Nav.NavigateTo("/tracks"); - } - else - { - var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - Snackbar.Add($"Delete failed: {error}", Severity.Error); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Delete failed for track {TrackId}", Id); - Snackbar.Add("Delete failed — please try again.", Severity.Error); - } - finally - { - _busy = false; - } - } - - private sealed class TrackEditForm - { - public string TrackName { get; set; } = string.Empty; - public string Artist { get; set; } = string.Empty; - public string? Album { get; set; } - public string? Genre { get; set; } - public string? ImagePath { get; set; } - public DateTime? ReleaseDate { get; set; } - public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; - - // Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta - // carries Medium). A non-Cut medium resets ReleaseType to its default server-side. - public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; - public int TrackNumber { get; set; } = 1; - - public static TrackEditForm From(TrackDto track) => new() - { - TrackName = track.TrackName, - Artist = track.Release?.Artist ?? string.Empty, - Album = track.Release?.Title, - Genre = track.Release?.Genre, - ImagePath = track.Release?.ImagePath, - ReleaseDate = track.Release?.ReleaseDate is { } d - ? d.ToDateTime(TimeOnly.MinValue) - : null, - ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single, - Medium = track.Release?.Medium ?? ReleaseMedium.Cut, - TrackNumber = track.TrackNumber - }; - } + protected override void OnInitialized() => + Navigation.NavigateTo($"/tracks/{Id}/edit", replace: true); } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor index 0ce625d..9c60e29 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor @@ -1,287 +1,12 @@ @page "/tracks/new" -@using System.Security.Claims -@using DeepDrftManager.Services -@using DeepDrftModels.Enums @attribute [Authorize] - -@inject ICmsTrackService CmsTrackService -@inject ICmsReleaseService CmsReleaseService -@inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation -@inject ISnackbar Snackbar -@inject ILogger Logger -@inject CmsTrackBrowserViewModel VM -Add Track — DeepDrft CMS - - - Add Track - - - - WAV file - - @if (_selectedFile is not null) - { - - Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size)) - - } - - - - - - - - - - - @if (_selectedImageFile is { } selectedImage) - { - - Selected: @selectedImage.Name - - - } - else - { - No cover art — optional. - } - - - @if (_selectedImageFile is not null) - { - Will upload on save. - } - - - - - - @if (!string.IsNullOrEmpty(_errorMessage)) - { - @_errorMessage - } - - - - Cancel - - - @if (_isUploading) - { - - Uploading… - } - else - { - Upload - } - - - - - +@* §8.M: the legacy single-track add form is retired. Its add responsibility is absorbed by + BatchUpload's single-track branch. This route is kept only as a redirect so any bookmarked + /tracks/new lands on the live upload surface; there are no in-app callers. *@ @code { - // 1 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 = 1_073_741_824L; - - private IBrowserFile? _selectedFile; - private IBrowserFile? _selectedImageFile; - private string? _imagePath; - private string _trackName = string.Empty; - private string _artist = string.Empty; - private string _album = string.Empty; - private string _genre = string.Empty; - private string _releaseDate = string.Empty; - private ReleaseType _releaseType = ReleaseType.Single; - private ReleaseMedium _medium = ReleaseMedium.Cut; - private string? _errorMessage; - private bool _isUploading; - - private void OnFileSelected(InputFileChangeEventArgs e) - { - _selectedFile = e.File; - _errorMessage = null; - } - - private void HandleImageFileSelected(InputFileChangeEventArgs e) - { - _selectedImageFile = e.File; - _imagePath = null; - } - - private void ClearImage() - { - _selectedImageFile = null; - _imagePath = null; - } - - private async Task SubmitAsync() - { - _errorMessage = null; - - if (_selectedFile is null) - { - _errorMessage = "Please select a WAV file."; - return; - } - - if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) - { - _errorMessage = "Selected file must be a .wav file."; - return; - } - - if (string.IsNullOrWhiteSpace(_trackName)) - { - _errorMessage = "Track Name is required."; - return; - } - - if (string.IsNullOrWhiteSpace(_artist)) - { - _errorMessage = "Artist is required."; - return; - } - - if (!string.IsNullOrWhiteSpace(_releaseDate) - && !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _)) - { - _errorMessage = "Release Date must be in YYYY-MM-DD format."; - 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 [HierarchicalRoleAuthorize(Admin)], 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; - } - - _isUploading = true; - try - { - // Upload any selected cover art first; abort the submit if it fails so we never - // create a track 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; - } - - // OpenReadStream streams chunks from the browser via the SignalR circuit; the - // service wraps it in StreamContent so the whole file is never materialised in - // memory before DeepDrftAPI receives it. - await using var fileStream = _selectedFile.OpenReadStream(MaxUploadBytes); - - var result = await CmsTrackService.UploadTrackAsync( - fileStream, - _selectedFile.Name, - _selectedFile.ContentType, - _trackName, - _artist, - string.IsNullOrWhiteSpace(_album) ? null : _album, - string.IsNullOrWhiteSpace(_genre) ? null : _genre, - string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate, - _selectedFile.Name, - createdByUserId, - _releaseType, - trackNumber: 1, - _medium); - - if (result.Success) - { - // Mix uploads fire the server-side high-res waveform trigger (§3.4) — the CMS computes - // nothing. Non-blocking: a failed trigger is recoverable from the Mixes browser. - if (_medium == ReleaseMedium.Mix && result.Value?.ReleaseId is { } mixReleaseId) - { - var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId); - if (!waveformResult.Success) - { - Logger.LogWarning("TrackNew: mix waveform trigger failed for release {ReleaseId}", mixReleaseId); - Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning); - } - } - - // The upload endpoint does not accept an imagePath, so link the cover art with a - // follow-up metadata update — same two-step pattern TrackEdit uses. - if (_imagePath is { } imgPath && result.Value is { } created) - { - var linkResult = await CmsTrackService.UpdateAsync( - created.Id, - _trackName, - _artist, - string.IsNullOrWhiteSpace(_album) ? null : _album, - string.IsNullOrWhiteSpace(_genre) ? null : _genre, - string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"), - imgPath); - if (!linkResult.Success) - { - // Track was created; image is in the vault but unlinked. Non-blocking — - // the user can attach it via Edit. - Snackbar.Add("Track uploaded, but cover art could not be linked. You can add it via Edit.", Severity.Warning); - } - } - - Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success); - VM.Invalidate(); - Navigation.NavigateTo("/tracks"); - return; - } - - var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - _errorMessage = $"Upload failed: {error}"; - Logger.LogWarning("CMS upload rejected: {Error}", error); - } - catch (Exception ex) - { - Logger.LogError(ex, "Upload failed in TrackNew"); - _errorMessage = "Upload failed. Please try again."; - } - finally - { - _isUploading = false; - } - } - - private void Cancel() - { - Navigation.NavigateTo("/tracks"); - } - - private static string FormatBytes(long bytes) - { - const long KB = 1024; - const long MB = KB * 1024; - const long GB = MB * 1024; - if (bytes >= GB) return $"{bytes / (double)GB:F2} GB"; - if (bytes >= MB) return $"{bytes / (double)MB:F2} MB"; - if (bytes >= KB) return $"{bytes / (double)KB:F2} KB"; - return $"{bytes} bytes"; - } + protected override void OnInitialized() => + Navigation.NavigateTo("/tracks/upload", replace: true); }