diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index e5e6ff0..7352b35 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -1,7 +1,9 @@ @page "/tracks/{Id:long}" @using DeepDrftManager.Services +@using Microsoft.AspNetCore.Components.Forms @attribute [Authorize] @inject ICmsTrackService CmsTrackService +@inject IHttpClientFactory HttpClientFactory @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Nav @@ -60,6 +62,36 @@ Label="Genre" Variant="Variant.Outlined" /> + + + @if (ImagePreviewUrl is { } previewUrl) + { + + + + + } + else + { + No cover art set. + } + + + @if (_selectedImageFile is { } selected) + { + Selected: @selected.Name (will upload on save) + } + + + !string.IsNullOrWhiteSpace(_form.TrackName) && !string.IsNullOrWhiteSpace(_form.Artist); + // The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser can hit + // DeepDrftAPI directly. Base address comes from the same named client the CMS uses for writes. + private string? ImagePreviewUrl + { + get + { + if (string.IsNullOrEmpty(_form.ImagePath)) return null; + var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress; + if (baseAddress is null) return null; + return new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(_form.ImagePath)}").ToString(); + } + } + protected override async Task OnInitializedAsync() { await LoadAsync(); @@ -123,14 +169,33 @@ _busy = true; try { - // Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content - // API loads the authoritative row and applies these fields. + // 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); + releaseDate, + string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath); if (updated.Success) { Snackbar.Add("Track updated.", Severity.Success); @@ -153,6 +218,17 @@ } } + 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; @@ -197,6 +273,7 @@ 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 static TrackEditForm From(TrackDto track) => new() @@ -205,6 +282,7 @@ Artist = track.Artist, Album = track.Album, Genre = track.Genre, + ImagePath = track.ImagePath, ReleaseDate = track.ReleaseDate is { } d ? d.ToDateTime(TimeOnly.MinValue) : null diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index cdeab43..12a314e 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -241,9 +241,77 @@ public class CmsTrackService : ICmsTrackService } } + public async Task> UploadImageAsync( + Stream imageStream, + string fileName, + string contentType, + CancellationToken ct = default) + { + using var multipart = new MultipartFormDataContent(); + var imageContent = new StreamContent(imageStream); + imageContent.Headers.ContentType = new MediaTypeHeaderValue( + string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType); + multipart.Add(imageContent, "image", fileName); + + var client = _httpClientFactory.CreateClient(ContentCmsClientName); + using var request = new HttpRequestMessage(HttpMethod.Post, "api/image/upload") { Content = multipart }; + + HttpResponseMessage response; + try + { + response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Content API call failed for image upload of {FileName}", fileName); + return ResultContainer.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + var statusCode = (int)response.StatusCode; + if (statusCode >= 500) + { + _logger.LogError("Content API returned {Status} for image upload of {FileName}: {Body}", statusCode, fileName, body); + return ResultContainer.CreateFailResult("Image upload failed on the content server."); + } + + // 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is. + _logger.LogWarning("Content API rejected image upload: {Status} {Body}", statusCode, body); + return ResultContainer.CreateFailResult( + string.IsNullOrWhiteSpace(body) ? $"Image upload rejected ({statusCode})." : body); + } + + ImageUploadResponse? payload; + try + { + payload = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize image upload response from Content API"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + if (payload is null || string.IsNullOrWhiteSpace(payload.EntryKey)) + { + _logger.LogError("Content API returned an empty entry key for image upload"); + return ResultContainer.CreateFailResult("Content API returned an empty response."); + } + + return ResultContainer.CreatePassResult(payload.EntryKey); + } + } + + private sealed record ImageUploadResponse(string EntryKey); + public async Task UpdateAsync( long id, string trackName, string artist, string? album, string? genre, DateOnly? releaseDate, + string? imagePath = null, CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); @@ -254,6 +322,7 @@ public class CmsTrackService : ICmsTrackService album, genre, releaseDate, + imagePath, }; HttpResponseMessage response; diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 0c54562..e98cff8 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -50,13 +50,25 @@ public interface ICmsTrackService /// Task> GetByIdAsync(long id, CancellationToken ct = default); + /// + /// Upload a cover-art image to the images vault via POST api/image/upload. + /// Returns the generated entry key on success. Maps a 400 to a validation failure message. + /// + Task> UploadImageAsync( + Stream imageStream, + string fileName, + string contentType, + CancellationToken ct = default); + /// /// Update a track's metadata via PUT api/track/meta/{id}. EntryKey is immutable and - /// not part of the update. + /// not part of the update. is tri-state: null leaves the + /// cover art unchanged, "" clears it, and any other value sets it. /// Task UpdateAsync( long id, string trackName, string artist, string? album, string? genre, DateOnly? releaseDate, + string? imagePath = null, CancellationToken ct = default); ///