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);
///