diff --git a/ErsatzTV.Application/Artworks/Commands/UploadArtwork.cs b/ErsatzTV.Application/Artworks/Commands/UploadArtwork.cs
new file mode 100644
index 000000000..e27c60b37
--- /dev/null
+++ b/ErsatzTV.Application/Artworks/Commands/UploadArtwork.cs
@@ -0,0 +1,13 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Api.Artwork;
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Application.Artworks;
+
+///
+/// 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 IImageCache),
+/// so the returned path is equivalent to a Blazor-uploaded image.
+///
+public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
+ : IRequest>;
diff --git a/ErsatzTV.Application/Artworks/Commands/UploadArtworkHandler.cs b/ErsatzTV.Application/Artworks/Commands/UploadArtworkHandler.cs
new file mode 100644
index 000000000..d8623baea
--- /dev/null
+++ b/ErsatzTV.Application/Artworks/Commands/UploadArtworkHandler.cs
@@ -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>
+{
+ // 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 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> 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 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
+ };
+}
diff --git a/ErsatzTV.Core/Api/Artwork/ArtworkUploadResponseModel.cs b/ErsatzTV.Core/Api/Artwork/ArtworkUploadResponseModel.cs
new file mode 100644
index 000000000..ee68a3fab
--- /dev/null
+++ b/ErsatzTV.Core/Api/Artwork/ArtworkUploadResponseModel.cs
@@ -0,0 +1,10 @@
+#nullable enable
+namespace ErsatzTV.Core.Api.Artwork;
+
+///
+/// Result of uploading channel logo / watermark artwork via the REST API.
+/// is directly consumable as the Path of an
+/// ArtworkContentTypeModel (e.g. CreateChannel.Logo / channel update),
+/// and carries the stored MIME type.
+///
+public record ArtworkUploadResponseModel(string Path, string ContentType);
diff --git a/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs b/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs
new file mode 100644
index 000000000..3a4792a90
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs
@@ -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();
+ _handler = new UploadArtworkHandler(_imageCache);
+ }
+
+ [Test]
+ public async Task Handle_Should_Return_Logo_Path_With_Iptv_Logos_Prefix()
+ {
+ _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Logo)
+ .Returns(Right("abc123.png"));
+
+ using var stream = new MemoryStream();
+ Either 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(), ArtworkKind.Watermark)
+ .Returns(Right("def456.webp"));
+
+ using var stream = new MemoryStream();
+ Either 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 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(), Arg.Any());
+ }
+
+ [Test]
+ public async Task Handle_Should_Propagate_Cache_Save_Failure()
+ {
+ _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Logo)
+ .Returns(Left(BaseError.New("disk full")));
+
+ using var stream = new MemoryStream();
+ Either result = await _handler.Handle(
+ new UploadArtwork(stream, "image/png", ArtworkKind.Logo),
+ CancellationToken.None);
+
+ LeftOf(result).Value.ShouldBe("disk full");
+ }
+
+ private static TR RightOf(Either either) =>
+ either.Match(Right: v => v, Left: e => throw new AssertionException($"Expected Right, got Left: {e.Value}"));
+
+ private static BaseError LeftOf(Either either) =>
+ either.Match(Right: _ => throw new AssertionException("Expected Left, got Right"), Left: e => e);
+}
diff --git a/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs
new file mode 100644
index 000000000..8d2f44457
--- /dev/null
+++ b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs
@@ -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();
+ _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(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();
+ 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();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ problem.Status.ShouldBe(422);
+ problem.Title.ShouldBe("Validation failed");
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [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();
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [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();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ problem.Detail.ShouldContain("maximum allowed size");
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [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();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ problem.Detail.ShouldContain("Unknown upload target");
+ await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any());
+ }
+
+ [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(), Arg.Any())
+ .Returns(Right(
+ new ArtworkUploadResponseModel("iptv/logos/abc.png", "image/png")));
+
+ IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
+
+ var created = result.ShouldBeOfType();
+ created.StatusCode.ShouldBe(201);
+ created.Location.ShouldBe("/iptv/logos/abc.png?contentType=image%2Fpng");
+ created.Value.ShouldBeOfType()
+ .Path.ShouldBe("iptv/logos/abc.png");
+
+ await _mediator.Received(1).Send(
+ Arg.Is(c => c.ArtworkKind == ArtworkKind.Logo && c.ContentType == "image/png"),
+ Arg.Any());
+ }
+
+ [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(), Arg.Any())
+ .Returns(Right(
+ new ArtworkUploadResponseModel("def.webp", "image/webp")));
+
+ IActionResult result = await _controller.Upload(file, "watermark", CancellationToken.None);
+
+ var created = result.ShouldBeOfType();
+ created.Location.ShouldBe("/artwork/watermarks/def.webp?contentType=image%2Fwebp");
+
+ await _mediator.Received(1).Send(
+ Arg.Is(c => c.ArtworkKind == ArtworkKind.Watermark),
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task Upload_Should_Return_422_On_Handler_Validation_Error()
+ {
+ IFormFile file = MakeFormFile([1, 2, 3], "image/bmp");
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Left(BaseError.New("unsupported content type")));
+
+ IActionResult result = await _controller.Upload(file, "logo", CancellationToken.None);
+
+ var unprocessable = result.ShouldBeOfType();
+ var problem = unprocessable.Value.ShouldBeOfType();
+ 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
+ };
+}
diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs
index e0222045d..1599f11c8 100644
--- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs
+++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs
@@ -69,6 +69,7 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "422")]
[TestCase("/api/playouts/{id}/items", "get", "404")]
+ [TestCase("/api/artwork/uploads", "post", "422")]
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "401")]
diff --git a/ErsatzTV/Controllers/Api/ArtworkUploadController.cs b/ErsatzTV/Controllers/Api/ArtworkUploadController.cs
new file mode 100644
index 000000000..487820a73
--- /dev/null
+++ b/ErsatzTV/Controllers/Api/ArtworkUploadController.cs
@@ -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 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 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}"
+ };
+ }
+}
diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json
index 6f4a48121..eb4ab1c7e 100644
--- a/ErsatzTV/wwwroot/openapi/v1.json
+++ b/ErsatzTV/wwwroot/openapi/v1.json
@@ -5,6 +5,85 @@
"version": "1.0.0"
},
"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": {
"get": {
"tags": [
@@ -3501,6 +3580,21 @@
}
}
},
+ "ArtworkUploadResponseModel": {
+ "required": [
+ "path",
+ "contentType"
+ ],
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "contentType": {
+ "type": "string"
+ }
+ }
+ },
"ChannelIdleBehavior": {
"enum": [
"StopOnDisconnect",
@@ -4727,6 +4821,10 @@
}
}
},
+ "IFormFile": {
+ "type": "string",
+ "format": "binary"
+ },
"LibraryMediaKind": {
"enum": [
"Movies",
@@ -6537,6 +6635,9 @@
}
},
"tags": [
+ {
+ "name": "Artwork"
+ },
{
"name": "Channel"
},