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" },