feat(api): artwork upload endpoint (#104)
POST /api/artwork/uploads (multipart/form-data) accepting logo and
watermark images. Validates content type (png/jpeg/gif/webp) and size
(SystemEnvironment.MaximumUploadMb, default 10MB) mirroring the Blazor
upload path; stores via IImageCache.SaveArtworkToCache; returns
{ path, contentType } consumable by channel create/update.
Includes handler + controller tests, OpenAPI 422 contract-test entry,
and regenerated v1.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
using System.ComponentModel;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Artwork;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class ArtworkUploadController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/artwork/uploads", Name = "UploadArtwork")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[Tags("Artwork")]
|
||||
[EndpointSummary("Upload channel logo or watermark artwork")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ArtworkUploadResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] [Description("Artwork target: 'logo' (default) or 'watermark'")] string target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BaseError.New("A non-empty image file is required").ToErrorResult();
|
||||
}
|
||||
|
||||
long maxBytes = (long)SystemEnvironment.MaximumUploadMb * 1024 * 1024;
|
||||
if (file.Length > maxBytes)
|
||||
{
|
||||
return BaseError.New($"Image exceeds the maximum allowed size of {SystemEnvironment.MaximumUploadMb} MB")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
if (!TryParseTarget(target, out ArtworkKind artworkKind))
|
||||
{
|
||||
return BaseError.New($"Unknown upload target '{target}'; expected 'logo' or 'watermark'").ToErrorResult();
|
||||
}
|
||||
|
||||
await using Stream stream = file.OpenReadStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> result = await mediator.Send(
|
||||
new UploadArtwork(stream, file.ContentType, artworkKind),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(
|
||||
value => LocationFor(artworkKind, value.Path, value.ContentType),
|
||||
value => value);
|
||||
}
|
||||
|
||||
// "logo" (default) and "watermark" are the two channel-artwork surfaces the API exposes today.
|
||||
private static bool TryParseTarget(string target, out ArtworkKind artworkKind)
|
||||
{
|
||||
switch ((target ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "":
|
||||
case "logo":
|
||||
artworkKind = ArtworkKind.Logo;
|
||||
return true;
|
||||
case "watermark":
|
||||
artworkKind = ArtworkKind.Watermark;
|
||||
return true;
|
||||
default:
|
||||
artworkKind = ArtworkKind.Logo;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Both GetImage (IptvController) and GetWatermark (ArtworkController) require a contentType
|
||||
// query param to serve the cached file, so the Location header must carry it too.
|
||||
private static string LocationFor(ArtworkKind artworkKind, string path, string contentType)
|
||||
{
|
||||
string encodedContentType = Uri.EscapeDataString(contentType);
|
||||
return artworkKind switch
|
||||
{
|
||||
// logo paths already carry the servable prefix ("iptv/logos/{file}")
|
||||
ArtworkKind.Logo => $"/{path}?contentType={encodedContentType}",
|
||||
_ => $"/artwork/watermarks/{path}?contentType={encodedContentType}"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user