feat(api): artwork upload endpoint (#104)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

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:
2026-07-03 23:32:22 +02:00
co-authored by Claude Fable 5
parent b9955f4cba
commit 3d086aabc1
8 changed files with 514 additions and 0 deletions
@@ -0,0 +1,13 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
/// <summary>
/// Validates and stores an uploaded image as channel logo or watermark artwork,
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
/// so the returned path is equivalent to a Blazor-uploaded image.
/// </summary>
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
};
private readonly IImageCache _imageCache;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
UploadArtwork request,
CancellationToken cancellationToken)
{
string contentType = (request.ContentType ?? string.Empty).Trim();
if (!AcceptedContentTypes.Contains(contentType))
{
return BaseError.New(
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
}
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
request.Stream,
request.ArtworkKind);
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
BuildPath(request.ArtworkKind, fileName),
contentType));
}
// Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
// for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
// (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
artworkKind switch
{
ArtworkKind.Logo => $"iptv/logos/{fileName}",
_ => fileName
};
}
@@ -0,0 +1,10 @@
#nullable enable
namespace ErsatzTV.Core.Api.Artwork;
/// <summary>
/// Result of uploading channel logo / watermark artwork via the REST API.
/// <see cref="Path" /> is directly consumable as the <c>Path</c> of an
/// <c>ArtworkContentTypeModel</c> (e.g. <c>CreateChannel.Logo</c> / channel update),
/// and <see cref="ContentType" /> carries the stored MIME type.
/// </summary>
public record ArtworkUploadResponseModel(string Path, string ContentType);
@@ -0,0 +1,87 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.Artworks;
[TestFixture]
public class UploadArtworkHandlerTests
{
private IImageCache _imageCache = null!;
private UploadArtworkHandler _handler = null!;
[SetUp]
public void SetUp()
{
_imageCache = Substitute.For<IImageCache>();
_handler = new UploadArtworkHandler(_imageCache);
}
[Test]
public async Task Handle_Should_Return_Logo_Path_With_Iptv_Logos_Prefix()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
.Returns(Right<BaseError, string>("abc123.png"));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result =
await _handler.Handle(new UploadArtwork(stream, "image/png", ArtworkKind.Logo), CancellationToken.None);
ArtworkUploadResponseModel response = RightOf(result);
response.Path.ShouldBe("iptv/logos/abc123.png");
response.ContentType.ShouldBe("image/png");
}
[Test]
public async Task Handle_Should_Return_Bare_File_Name_For_Watermark()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Watermark)
.Returns(Right<BaseError, string>("def456.webp"));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/webp", ArtworkKind.Watermark),
CancellationToken.None);
RightOf(result).Path.ShouldBe("def456.webp");
}
[Test]
public async Task Handle_Should_Reject_Unsupported_Content_Type()
{
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/bmp", ArtworkKind.Logo),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Unsupported image content type");
await _imageCache.DidNotReceive().SaveArtworkToCache(Arg.Any<Stream>(), Arg.Any<ArtworkKind>());
}
[Test]
public async Task Handle_Should_Propagate_Cache_Save_Failure()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
.Returns(Left<BaseError, string>(BaseError.New("disk full")));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/png", ArtworkKind.Logo),
CancellationToken.None);
LeftOf(result).Value.ShouldBe("disk full");
}
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Right: v => v, Left: e => throw new AssertionException($"Expected Right, got Left: {e.Value}"));
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Right: _ => throw new AssertionException("Expected Left, got Right"), Left: e => e);
}
@@ -0,0 +1,164 @@
using System.Reflection;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ArtworkUploadControllerTests
{
private ArtworkUploadController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ArtworkUploadController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(ArtworkUploadController).GetMethod(nameof(ArtworkUploadController.Upload))
?? throw new AssertionException("Missing action Upload");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("POST");
attribute.Template.ShouldBe("/api/artwork/uploads");
attribute.Name.ShouldBe("UploadArtwork");
}
[Test]
public void Action_Should_Consume_Multipart_Form_Data()
{
MethodInfo action = typeof(ArtworkUploadController).GetMethod(nameof(ArtworkUploadController.Upload))
?? throw new AssertionException("Missing action Upload");
var consumes = action.GetCustomAttribute<ConsumesAttribute>();
consumes.ShouldNotBeNull();
consumes.ContentTypes.ShouldContain("multipart/form-data");
}
[Test]
public async Task Upload_Should_Return_422_When_File_Missing()
{
IActionResult result = await _controller.Upload(null!, "logo", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Title.ShouldBe("Validation failed");
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_When_File_Empty()
{
IFormFile emptyFile = MakeFormFile([], "image/png");
IActionResult result = await _controller.Upload(emptyFile, "logo", CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_When_File_Exceeds_Maximum_Size()
{
var oversizeBytes = new byte[(SystemEnvironment.MaximumUploadMb * 1024 * 1024) + 1];
IFormFile oversizeFile = MakeFormFile(oversizeBytes, "image/png");
IActionResult result = await _controller.Upload(oversizeFile, "logo", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Detail.ShouldContain("maximum allowed size");
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_For_Unknown_Target()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/png");
IActionResult result = await _controller.Upload(file, "poster", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Detail.ShouldContain("Unknown upload target");
await _mediator.DidNotReceive().Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Send_UploadArtwork_With_Logo_Kind_And_Return_201()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/png");
_mediator.Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ArtworkUploadResponseModel>(
new ArtworkUploadResponseModel("iptv/logos/abc.png", "image/png")));
IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/iptv/logos/abc.png?contentType=image%2Fpng");
created.Value.ShouldBeOfType<ArtworkUploadResponseModel>()
.Path.ShouldBe("iptv/logos/abc.png");
await _mediator.Received(1).Send(
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Logo && c.ContentType == "image/png"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Send_UploadArtwork_With_Watermark_Kind_And_Return_201()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/webp");
_mediator.Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ArtworkUploadResponseModel>(
new ArtworkUploadResponseModel("def.webp", "image/webp")));
IActionResult result = await _controller.Upload(file, "watermark", CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/artwork/watermarks/def.webp?contentType=image%2Fwebp");
await _mediator.Received(1).Send(
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Watermark),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Upload_Should_Return_422_On_Handler_Validation_Error()
{
IFormFile file = MakeFormFile([1, 2, 3], "image/bmp");
_mediator.Send(Arg.Any<UploadArtwork>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ArtworkUploadResponseModel>(BaseError.New("unsupported content type")));
IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Title.ShouldBe("Validation failed");
}
private static IFormFile MakeFormFile(byte[] bytes, string contentType) =>
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "upload.bin")
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
@@ -69,6 +69,7 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts/{id}", "delete", "404")] [TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "422")] [TestCase("/api/playouts/{id}", "delete", "422")]
[TestCase("/api/playouts/{id}/items", "get", "404")] [TestCase("/api/playouts/{id}/items", "get", "404")]
[TestCase("/api/artwork/uploads", "post", "422")]
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")] [TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "404")] [TestCase("/api/ffmpeg/profiles", "post", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "401")] [TestCase("/api/ffmpeg/profiles", "post", "401")]
@@ -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}"
};
}
}
+101
View File
@@ -5,6 +5,85 @@
"version": "1.0.0" "version": "1.0.0"
}, },
"paths": { "paths": {
"/api/artwork/uploads": {
"post": {
"tags": [
"Artwork"
],
"summary": "Upload channel logo or watermark artwork",
"operationId": "UploadArtwork",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"allOf": [
{
"type": "object",
"properties": {
"file": {
"$ref": "#/components/schemas/IFormFile"
}
}
},
{
"type": "object",
"properties": {
"target": {
"type": "string"
}
}
}
]
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ArtworkUploadResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArtworkUploadResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ArtworkUploadResponseModel"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/channels": { "/api/channels": {
"get": { "get": {
"tags": [ "tags": [
@@ -3501,6 +3580,21 @@
} }
} }
}, },
"ArtworkUploadResponseModel": {
"required": [
"path",
"contentType"
],
"type": "object",
"properties": {
"path": {
"type": "string"
},
"contentType": {
"type": "string"
}
}
},
"ChannelIdleBehavior": { "ChannelIdleBehavior": {
"enum": [ "enum": [
"StopOnDisconnect", "StopOnDisconnect",
@@ -4727,6 +4821,10 @@
} }
} }
}, },
"IFormFile": {
"type": "string",
"format": "binary"
},
"LibraryMediaKind": { "LibraryMediaKind": {
"enum": [ "enum": [
"Movies", "Movies",
@@ -6537,6 +6635,9 @@
} }
}, },
"tags": [ "tags": [
{
"name": "Artwork"
},
{ {
"name": "Channel" "name": "Channel"
}, },