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,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}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user