From d1dfe6eb5a861b16288f93e9b4b01a6f272b5275 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 1 Jul 2026 22:39:16 +0200 Subject: [PATCH 01/19] docs: add Codex verification guidance --- AGENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..54119b593 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Codex Instructions + +## Verification Commands + +Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution. + +Preferred verification commands: + +```bash +TZ=UTC dotnet restore ErsatzTV.sln -v minimal +TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal +TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal +``` + +Use scoped escalated execution for these commands rather than first trying a sandboxed run. From 8912686a478afc75b3acb6239220530fad1d60cb Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:40 +0200 Subject: [PATCH 02/19] wip: partial implementation salvaged from interrupted workflow run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untrusted draft — no build/test had run yet. Review before building on it. Co-Authored-By: Claude Fable 5 --- ErsatzTV.Application/Filler/Mapper.cs | 6 +- .../Queries/GetAllFillerPresetsForApi.cs | 5 ++ .../GetAllFillerPresetsForApiHandler.cs | 22 +++++++ .../Queries/GetAllGraphicsElementsForApi.cs | 5 ++ .../GetAllGraphicsElementsForApiHandler.cs | 27 ++++++++ ErsatzTV.Application/Watermarks/Mapper.cs | 4 ++ .../Queries/GetAllWatermarksForApi.cs | 5 ++ .../Queries/GetAllWatermarksForApiHandler.cs | 22 +++++++ .../Api/Filler/FillerPresetResponseModel.cs | 3 + .../Graphics/GraphicsElementResponseModel.cs | 3 + .../Api/Watermarks/WatermarkResponseModel.cs | 3 + .../FillerPresetControllerTests.cs | 63 +++++++++++++++++++ .../GraphicsElementControllerTests.cs | 63 +++++++++++++++++++ .../Controllers/WatermarkControllerTests.cs | 63 +++++++++++++++++++ .../Controllers/Api/FillerPresetController.cs | 19 ++++++ .../Api/GraphicsElementController.cs | 19 ++++++ .../Controllers/Api/WatermarkController.cs | 19 ++++++ 17 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs create mode 100644 ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs create mode 100644 ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApi.cs create mode 100644 ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs create mode 100644 ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApi.cs create mode 100644 ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApiHandler.cs create mode 100644 ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs create mode 100644 ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/FillerPresetController.cs create mode 100644 ErsatzTV/Controllers/Api/GraphicsElementController.cs create mode 100644 ErsatzTV/Controllers/Api/WatermarkController.cs diff --git a/ErsatzTV.Application/Filler/Mapper.cs b/ErsatzTV.Application/Filler/Mapper.cs index 6a4ad35eb..1b72b5200 100644 --- a/ErsatzTV.Application/Filler/Mapper.cs +++ b/ErsatzTV.Application/Filler/Mapper.cs @@ -1,9 +1,13 @@ -using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain.Filler; namespace ErsatzTV.Application.Filler; internal static class Mapper { + internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) => + new(fillerPreset.Id, fillerPreset.Name); + internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) => new( fillerPreset.Id, diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs new file mode 100644 index 000000000..3252aae1f --- /dev/null +++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Filler; + +namespace ErsatzTV.Application.Filler; + +public record GetAllFillerPresetsForApi : IRequest>; diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs new file mode 100644 index 000000000..b0c6edaf6 --- /dev/null +++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs @@ -0,0 +1,22 @@ +using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Filler.Mapper; + +namespace ErsatzTV.Application.Filler; + +public class GetAllFillerPresetsForApiHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetAllFillerPresetsForApi request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + List fillerPresets = await dbContext.FillerPresets + .AsNoTracking() + .ToListAsync(cancellationToken); + return fillerPresets.Map(ProjectToResponseModel).ToList(); + } +} diff --git a/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApi.cs b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApi.cs new file mode 100644 index 000000000..534ecf73f --- /dev/null +++ b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Graphics; + +namespace ErsatzTV.Application.Graphics; + +public record GetAllGraphicsElementsForApi : IRequest>; diff --git a/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs new file mode 100644 index 000000000..c6131d4ab --- /dev/null +++ b/ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs @@ -0,0 +1,27 @@ +using ErsatzTV.Core.Api.Graphics; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Graphics.Mapper; + +namespace ErsatzTV.Application.Graphics; + +public class GetAllGraphicsElementsForApiHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetAllGraphicsElementsForApi request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + List graphicsElements = await dbContext.GraphicsElements + .AsNoTracking() + .ToListAsync(cancellationToken); + return graphicsElements + .Map(ProjectToViewModel) + .OrderBy(e => e.Name == e.FileName) + .ThenBy(e => e.Name) + .Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name)) + .ToList(); + } +} diff --git a/ErsatzTV.Application/Watermarks/Mapper.cs b/ErsatzTV.Application/Watermarks/Mapper.cs index 537932caa..4869d2ddc 100644 --- a/ErsatzTV.Application/Watermarks/Mapper.cs +++ b/ErsatzTV.Application/Watermarks/Mapper.cs @@ -1,10 +1,14 @@ using ErsatzTV.Application.Artworks; +using ErsatzTV.Core.Api.Watermarks; using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.Watermarks; internal static class Mapper { + internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) => + new(watermark.Id, watermark.Name); + public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) => new( watermark.Id, diff --git a/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApi.cs b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApi.cs new file mode 100644 index 000000000..acf3b86a9 --- /dev/null +++ b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Watermarks; + +namespace ErsatzTV.Application.Watermarks; + +public record GetAllWatermarksForApi : IRequest>; diff --git a/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApiHandler.cs b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApiHandler.cs new file mode 100644 index 000000000..758fe6349 --- /dev/null +++ b/ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksForApiHandler.cs @@ -0,0 +1,22 @@ +using ErsatzTV.Core.Api.Watermarks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Watermarks.Mapper; + +namespace ErsatzTV.Application.Watermarks; + +public class GetAllWatermarksForApiHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetAllWatermarksForApi request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + List watermarks = await dbContext.ChannelWatermarks + .AsNoTracking() + .ToListAsync(cancellationToken); + return watermarks.Map(ProjectToResponseModel).ToList(); + } +} diff --git a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs new file mode 100644 index 000000000..a237a0b3b --- /dev/null +++ b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.Filler; + +public record FillerPresetResponseModel(int Id, string Name); diff --git a/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs b/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs new file mode 100644 index 000000000..fef16a496 --- /dev/null +++ b/ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.Graphics; + +public record GraphicsElementResponseModel(int Id, string Name); diff --git a/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs new file mode 100644 index 000000000..656e5b550 --- /dev/null +++ b/ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.Watermarks; + +public record WatermarkResponseModel(int Id, string Name); diff --git a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs new file mode 100644 index 000000000..5747efc03 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using ErsatzTV.Application.Filler; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Filler; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class FillerPresetControllerTests +{ + private FillerPresetController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new FillerPresetController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(FillerPresetController).GetMethod(nameof(FillerPresetController.GetAll)) + ?? throw new AssertionException("Missing action GetAll"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/filler-presets"); + } + + [Test] + public async Task GetAll_Should_Return_FillerPresets() + { + List models = + [ + new FillerPresetResponseModel(1, "Intro"), + new FillerPresetResponseModel(2, "Outro") + ]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(models); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(models); + } + + [Test] + public async Task GetAll_Should_Return_Empty_List_When_None_Exist() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([]); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBeEmpty(); + } +} diff --git a/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs b/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs new file mode 100644 index 000000000..8a366e9d4 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using ErsatzTV.Application.Graphics; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Graphics; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class GraphicsElementControllerTests +{ + private GraphicsElementController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new GraphicsElementController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(GraphicsElementController).GetMethod(nameof(GraphicsElementController.GetAll)) + ?? throw new AssertionException("Missing action GetAll"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/graphics-elements"); + } + + [Test] + public async Task GetAll_Should_Return_GraphicsElements() + { + List models = + [ + new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)"), + new GraphicsElementResponseModel(2, "bug.png") + ]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(models); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(models); + } + + [Test] + public async Task GetAll_Should_Return_Empty_List_When_None_Exist() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([]); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBeEmpty(); + } +} diff --git a/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs new file mode 100644 index 000000000..1a2bafa9b --- /dev/null +++ b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using ErsatzTV.Application.Watermarks; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Watermarks; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class WatermarkControllerTests +{ + private WatermarkController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new WatermarkController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(WatermarkController).GetMethod(nameof(WatermarkController.GetAll)) + ?? throw new AssertionException("Missing action GetAll"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/watermarks"); + } + + [Test] + public async Task GetAll_Should_Return_Watermarks() + { + List models = + [ + new WatermarkResponseModel(1, "Corner Logo"), + new WatermarkResponseModel(2, "Ticker") + ]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(models); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(models); + } + + [Test] + public async Task GetAll_Should_Return_Empty_List_When_None_Exist() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([]); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBeEmpty(); + } +} diff --git a/ErsatzTV/Controllers/Api/FillerPresetController.cs b/ErsatzTV/Controllers/Api/FillerPresetController.cs new file mode 100644 index 000000000..77e0e2e0a --- /dev/null +++ b/ErsatzTV/Controllers/Api/FillerPresetController.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Application.Filler; +using ErsatzTV.Core.Api.Filler; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class FillerPresetController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/filler-presets")] + [Tags("Filler Presets")] + [EndpointSummary("Get all filler presets")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken); +} diff --git a/ErsatzTV/Controllers/Api/GraphicsElementController.cs b/ErsatzTV/Controllers/Api/GraphicsElementController.cs new file mode 100644 index 000000000..f0b5ef5be --- /dev/null +++ b/ErsatzTV/Controllers/Api/GraphicsElementController.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Application.Graphics; +using ErsatzTV.Core.Api.Graphics; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class GraphicsElementController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/graphics-elements")] + [Tags("Graphics Elements")] + [EndpointSummary("Get all graphics elements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken); +} diff --git a/ErsatzTV/Controllers/Api/WatermarkController.cs b/ErsatzTV/Controllers/Api/WatermarkController.cs new file mode 100644 index 000000000..2ec628fb7 --- /dev/null +++ b/ErsatzTV/Controllers/Api/WatermarkController.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Application.Watermarks; +using ErsatzTV.Core.Api.Watermarks; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class WatermarkController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/watermarks")] + [Tags("Watermarks")] + [EndpointSummary("Get all watermarks")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllWatermarksForApi(), cancellationToken); +} From 0eaedb9cf60e9aab315aaca5039f0de523c8b07a Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 22:06:19 +0200 Subject: [PATCH 03/19] feat(api): add route names and handler tests for picker list endpoints (#105) The WIP GET /api/filler-presets, /api/watermarks, and /api/graphics-elements endpoints omitted the Name= route attribute used by the other API GET actions (e.g. FFmpegProfileController). Add Name = "GetFillerPresets" / "GetWatermarks" / "GetGraphicsElements" for consistency. Also add handler tests (FillerPresetHandlerTests, WatermarkHandlerTests, GraphicsElementHandlerTests) covering the GetAll*ForApi handlers using the InMemoryTvContext harness: multi-row projection to (Id, Name) DTOs, the empty-DB case, and the graphics-element ordering rule (named elements sort before elements whose projected Name equals FileName). --- .../Filler/FillerPresetHandlerTests.cs | 63 +++++++++++++++ .../Graphics/GraphicsElementHandlerTests.cs | 77 +++++++++++++++++++ .../Watermarks/WatermarkHandlerTests.cs | 71 +++++++++++++++++ .../Controllers/Api/FillerPresetController.cs | 2 +- .../Api/GraphicsElementController.cs | 2 +- .../Controllers/Api/WatermarkController.cs | 2 +- 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs create mode 100644 ErsatzTV.Tests/Application/Graphics/GraphicsElementHandlerTests.cs create mode 100644 ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs diff --git a/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs new file mode 100644 index 000000000..6c810cffd --- /dev/null +++ b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs @@ -0,0 +1,63 @@ +using ErsatzTV.Application.Filler; +using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Filler; + +[TestFixture] +public class FillerPresetHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task GetAllFillerPresetsForApi_Should_Return_All_Presets() + { + await SeedPreset(1, "Intro"); + await SeedPreset(2, "Outro"); + + var handler = new GetAllFillerPresetsForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None); + + result.Count.ShouldBe(2); + result.ShouldContain(new FillerPresetResponseModel(1, "Intro")); + result.ShouldContain(new FillerPresetResponseModel(2, "Outro")); + } + + [Test] + public async Task GetAllFillerPresetsForApi_Should_Return_Empty_List_When_None_Exist() + { + var handler = new GetAllFillerPresetsForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None); + + result.ShouldBeEmpty(); + } + + private async Task SeedPreset(int id, string name) + { + await using TvContext context = _db.CreateContext(); + context.FillerPresets.Add(new FillerPreset + { + Id = id, + Name = name, + FillerKind = FillerKind.PreRoll, + FillerMode = FillerMode.Duration, + CollectionType = CollectionType.Collection + }); + await context.SaveChangesAsync(); + } +} diff --git a/ErsatzTV.Tests/Application/Graphics/GraphicsElementHandlerTests.cs b/ErsatzTV.Tests/Application/Graphics/GraphicsElementHandlerTests.cs new file mode 100644 index 000000000..d68fa1ca0 --- /dev/null +++ b/ErsatzTV.Tests/Application/Graphics/GraphicsElementHandlerTests.cs @@ -0,0 +1,77 @@ +using ErsatzTV.Application.Graphics; +using ErsatzTV.Core.Api.Graphics; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Graphics; + +[TestFixture] +public class GraphicsElementHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task GetAllGraphicsElementsForApi_Should_Return_All_Elements() + { + await SeedElement(1, "watermark.png", GraphicsElementKind.Image, "Custom Watermark"); + await SeedElement(2, "clock.png", GraphicsElementKind.Image, string.Empty); + + var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None); + + result.Count.ShouldBe(2); + result.Select(e => e.Id).ShouldBe([1, 2]); + } + + [Test] + public async Task GetAllGraphicsElementsForApi_Should_Order_Named_Elements_Before_Unnamed() + { + // unnamed element's projected Name equals its FileName -> should sort after named elements + await SeedElement(1, "aaa.png", GraphicsElementKind.Image, string.Empty); + await SeedElement(2, "zzz.png", GraphicsElementKind.Image, "A Custom Name"); + + var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None); + + result.Count.ShouldBe(2); + result[0].Id.ShouldBe(2); + result[1].Id.ShouldBe(1); + } + + [Test] + public async Task GetAllGraphicsElementsForApi_Should_Return_Empty_List_When_None_Exist() + { + var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None); + + result.ShouldBeEmpty(); + } + + private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name) + { + await using TvContext context = _db.CreateContext(); + context.GraphicsElements.Add(new GraphicsElement + { + Id = id, + Path = path, + Name = name, + Kind = kind + }); + await context.SaveChangesAsync(); + } +} diff --git a/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs b/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs new file mode 100644 index 000000000..173ce4798 --- /dev/null +++ b/ErsatzTV.Tests/Application/Watermarks/WatermarkHandlerTests.cs @@ -0,0 +1,71 @@ +using ErsatzTV.Application.Watermarks; +using ErsatzTV.Core.Api.Watermarks; +using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Watermarks; + +[TestFixture] +public class WatermarkHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task GetAllWatermarksForApi_Should_Return_All_Watermarks() + { + await SeedWatermark(1, "Bug"); + await SeedWatermark(2, "Logo"); + + var handler = new GetAllWatermarksForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None); + + result.Count.ShouldBe(2); + result.ShouldContain(new WatermarkResponseModel(1, "Bug")); + result.ShouldContain(new WatermarkResponseModel(2, "Logo")); + } + + [Test] + public async Task GetAllWatermarksForApi_Should_Return_Empty_List_When_None_Exist() + { + var handler = new GetAllWatermarksForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None); + + result.ShouldBeEmpty(); + } + + private async Task SeedWatermark(int id, string name) + { + await using TvContext context = _db.CreateContext(); + context.ChannelWatermarks.Add(new ChannelWatermark + { + Id = id, + Name = name, + Mode = ChannelWatermarkMode.Permanent, + ImageSource = ChannelWatermarkImageSource.Custom, + Image = "watermark.png", + Location = WatermarkLocation.BottomRight, + Size = WatermarkSize.Scaled, + WidthPercent = 10, + HorizontalMarginPercent = 2, + VerticalMarginPercent = 2, + FrequencyMinutes = 15, + DurationSeconds = 30, + Opacity = 100 + }); + await context.SaveChangesAsync(); + } +} diff --git a/ErsatzTV/Controllers/Api/FillerPresetController.cs b/ErsatzTV/Controllers/Api/FillerPresetController.cs index 77e0e2e0a..ba4d8de77 100644 --- a/ErsatzTV/Controllers/Api/FillerPresetController.cs +++ b/ErsatzTV/Controllers/Api/FillerPresetController.cs @@ -9,7 +9,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] public class FillerPresetController(IMediator mediator) : ControllerBase { - [HttpGet("/api/filler-presets")] + [HttpGet("/api/filler-presets", Name = "GetFillerPresets")] [Tags("Filler Presets")] [EndpointSummary("Get all filler presets")] [EndpointGroupName("general")] diff --git a/ErsatzTV/Controllers/Api/GraphicsElementController.cs b/ErsatzTV/Controllers/Api/GraphicsElementController.cs index f0b5ef5be..7196f1f14 100644 --- a/ErsatzTV/Controllers/Api/GraphicsElementController.cs +++ b/ErsatzTV/Controllers/Api/GraphicsElementController.cs @@ -9,7 +9,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] public class GraphicsElementController(IMediator mediator) : ControllerBase { - [HttpGet("/api/graphics-elements")] + [HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")] [Tags("Graphics Elements")] [EndpointSummary("Get all graphics elements")] [EndpointGroupName("general")] diff --git a/ErsatzTV/Controllers/Api/WatermarkController.cs b/ErsatzTV/Controllers/Api/WatermarkController.cs index 2ec628fb7..4b2259a7f 100644 --- a/ErsatzTV/Controllers/Api/WatermarkController.cs +++ b/ErsatzTV/Controllers/Api/WatermarkController.cs @@ -9,7 +9,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] public class WatermarkController(IMediator mediator) : ControllerBase { - [HttpGet("/api/watermarks")] + [HttpGet("/api/watermarks", Name = "GetWatermarks")] [Tags("Watermarks")] [EndpointSummary("Get all watermarks")] [EndpointGroupName("general")] From 446f50763a80d622b39e7acaf5dc1c1cf1873a4d Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 22:06:24 +0200 Subject: [PATCH 04/19] chore(openapi): regenerate v1.json for picker list endpoints (#105) Adds /api/filler-presets, /api/watermarks, and /api/graphics-elements to the published OpenAPI document via ./scripts/update-openapi.sh. --- ErsatzTV/wwwroot/openapi/v1.json | 186 +++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index e52fa2841..3df42d662 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -1353,6 +1353,86 @@ } } }, + "/api/filler-presets": { + "get": { + "tags": [ + "Filler Presets" + ], + "summary": "Get all filler presets", + "operationId": "GetFillerPresets", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FillerPresetResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FillerPresetResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FillerPresetResponseModel" + } + } + } + } + } + } + } + }, + "/api/graphics-elements": { + "get": { + "tags": [ + "Graphics Elements" + ], + "summary": "Get all graphics elements", + "operationId": "GetGraphicsElements", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GraphicsElementResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GraphicsElementResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GraphicsElementResponseModel" + } + } + } + } + } + } + } + }, "/api/libraries/{id}/scan": { "post": { "tags": [ @@ -2906,6 +2986,46 @@ } } } + }, + "/api/watermarks": { + "get": { + "tags": [ + "Watermarks" + ], + "summary": "Get all watermarks", + "operationId": "GetWatermarks", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WatermarkResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WatermarkResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WatermarkResponseModel" + } + } + } + } + } + } + } } }, "components": { @@ -4017,6 +4137,25 @@ ], "type": "string" }, + "FillerPresetResponseModel": { + "required": [ + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "FillerPresetViewModel": { "required": [ "id", @@ -4145,6 +4284,25 @@ ], "type": "string" }, + "GraphicsElementResponseModel": { + "required": [ + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "GraphicsElementViewModel": { "required": [ "id", @@ -5660,6 +5818,25 @@ "WatermarkLocation": { "type": "integer" }, + "WatermarkResponseModel": { + "required": [ + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "WatermarkSize": { "type": "integer" }, @@ -5763,6 +5940,12 @@ { "name": "FFmpeg Profiles" }, + { + "name": "Filler Presets" + }, + { + "name": "Graphics Elements" + }, { "name": "Libraries" }, @@ -5786,6 +5969,9 @@ }, { "name": "Version" + }, + { + "name": "Watermarks" } ] } \ No newline at end of file From 83c9122b6f820a759a1f096c71db4f51cdacc8e0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:40 +0200 Subject: [PATCH 05/19] wip: partial implementation salvaged from interrupted workflow run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untrusted draft — no build/test had run yet. Review before building on it. Co-Authored-By: Claude Fable 5 --- ErsatzTV.Application/Health/Mapper.cs | 25 ++++ .../Queries/GetAllHealthCheckResultsForApi.cs | 5 + .../GetAllHealthCheckResultsForApiHandler.cs | 32 +++++ .../Api/Health/HealthCheckResponseModel.cs | 7 ++ ...AllHealthCheckResultsForApiHandlerTests.cs | 111 ++++++++++++++++++ .../Controllers/HealthControllerTests.cs | 40 +++++++ ErsatzTV/Controllers/Api/HealthController.cs | 18 +++ 7 files changed, 238 insertions(+) create mode 100644 ErsatzTV.Application/Health/Mapper.cs create mode 100644 ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs create mode 100644 ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs create mode 100644 ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs create mode 100644 ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/HealthControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/HealthController.cs diff --git a/ErsatzTV.Application/Health/Mapper.cs b/ErsatzTV.Application/Health/Mapper.cs new file mode 100644 index 000000000..50c5fa61a --- /dev/null +++ b/ErsatzTV.Application/Health/Mapper.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core.Api.Health; +using ErsatzTV.Core.Health; + +namespace ErsatzTV.Application.Health; + +internal static class Mapper +{ + internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) => + new( + result.Title, + GetStatus(result.Status), + result.Message, + result.Link.Match(l => l.Link, () => null)); + + private static string GetStatus(HealthCheckStatus status) => + status switch + { + HealthCheckStatus.Pass => "pass", + HealthCheckStatus.Fail => "fail", + HealthCheckStatus.Warning => "warn", + HealthCheckStatus.Info => "info", + HealthCheckStatus.NotApplicable => "info", + _ => throw new ArgumentOutOfRangeException(nameof(status)) + }; +} diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs new file mode 100644 index 000000000..a3062a2f7 --- /dev/null +++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Health; + +namespace ErsatzTV.Application.Health; + +public record GetAllHealthCheckResultsForApi : IRequest>; diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs new file mode 100644 index 000000000..334d4cd28 --- /dev/null +++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs @@ -0,0 +1,32 @@ +using ErsatzTV.Core.Api.Health; +using ErsatzTV.Core.Health; +using static ErsatzTV.Application.Health.Mapper; + +namespace ErsatzTV.Application.Health; + +public class GetAllHealthCheckResultsForApiHandler + : IRequestHandler> +{ + private readonly IHealthCheckService _healthCheckService; + + public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) => + _healthCheckService = healthCheckService; + + public async Task> Handle( + GetAllHealthCheckResultsForApi request, + CancellationToken cancellationToken) + { + try + { + List results = await _healthCheckService.PerformHealthChecks(cancellationToken); + return results + .Filter(r => r.Status != HealthCheckStatus.NotApplicable) + .Map(ProjectToResponseModel) + .ToList(); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return []; + } + } +} diff --git a/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs new file mode 100644 index 000000000..37be2a34c --- /dev/null +++ b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Api.Health; + +public record HealthCheckResponseModel( + string Title, + string Status, + string Detail, + string Link); diff --git a/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs new file mode 100644 index 000000000..d0e9a0ff4 --- /dev/null +++ b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs @@ -0,0 +1,111 @@ +using ErsatzTV.Application.Health; +using ErsatzTV.Core.Api.Health; +using ErsatzTV.Core.Health; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Health; + +[TestFixture] +public class GetAllHealthCheckResultsForApiHandlerTests +{ + private IHealthCheckService _healthCheckService = null!; + private GetAllHealthCheckResultsForApiHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _healthCheckService = Substitute.For(); + _handler = new GetAllHealthCheckResultsForApiHandler(_healthCheckService); + } + + [Test] + public async Task Should_Map_Status_Codes_To_Lowercase_Strings() + { + var results = new List + { + new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option.None), + new("Fail Check", HealthCheckStatus.Fail, "broken", "bad", Option.None), + new("Warn Check", HealthCheckStatus.Warning, "watch out", "warn", Option.None), + new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option.None) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response.Count.ShouldBe(4); + response.Select(r => r.Status).ShouldBe(["pass", "fail", "warn", "info"]); + } + + [Test] + public async Task Should_Filter_Out_NotApplicable_Results() + { + var results = new List + { + new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option.None), + new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option.None) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response.Count.ShouldBe(1); + response[0].Title.ShouldBe("Pass Check"); + } + + [Test] + public async Task Should_Include_Link_When_Present() + { + var results = new List + { + new( + "Linked Check", + HealthCheckStatus.Warning, + "detail message", + "brief", + Option.Some(new HealthCheckLink("https://example.com/docs"))) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response[0].Link.ShouldBe("https://example.com/docs"); + response[0].Detail.ShouldBe("detail message"); + } + + [Test] + public async Task Should_Have_Null_Link_When_Absent() + { + var results = new List + { + new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option.None) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response[0].Link.ShouldBeNull(); + } + + [Test] + public async Task Should_Return_Empty_List_On_Cancellation() + { + _healthCheckService.PerformHealthChecks(Arg.Any()) + .Returns>>(_ => throw new TaskCanceledException()); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response.ShouldBeEmpty(); + } +} diff --git a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs new file mode 100644 index 000000000..113732a89 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs @@ -0,0 +1,40 @@ +using ErsatzTV.Application.Health; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Health; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class HealthControllerTests +{ + private IMediator _mediator = null!; + private HealthController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new HealthController(_mediator); + } + + [Test] + public async Task GetAll_Should_Return_Results_From_Mediator() + { + var expected = new List + { + new("Check One", "pass", "all good", null), + new("Check Two", "fail", "broken", "https://example.com") + }; + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(expected); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(expected); + } +} diff --git a/ErsatzTV/Controllers/Api/HealthController.cs b/ErsatzTV/Controllers/Api/HealthController.cs new file mode 100644 index 000000000..3c865fd49 --- /dev/null +++ b/ErsatzTV/Controllers/Api/HealthController.cs @@ -0,0 +1,18 @@ +using ErsatzTV.Application.Health; +using ErsatzTV.Core.Api.Health; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +[EndpointGroupName("general")] +public class HealthController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/health")] + [Tags("Health")] + [EndpointSummary("Get health check results")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken); +} From 29407f637be45ec7ffece7096f8503dc37eba973 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 21:14:42 +0200 Subject: [PATCH 06/19] fix(api): align health endpoint with API conventions (#108) Fix the WIP health-check API slice to match established patterns: - HealthController: add Name="GetHealthChecks" route name and move [EndpointGroupName("general")] to method level, matching FillerPresetController/FFmpegProfileController exactly (the precedent for parameterless 200-only GET actions). - HealthCheckResponseModel: enable #nullable for the file and mark Link as string? since the mapper can emit null when HealthCheckResult.Link is None. - Mapper: fix a real bug - LanguageExt's Option.Match throws ResultIsNullException.ResultIsNull if either branch returns null (by design, to catch accidental nulls). The WIP's `Link.Match(l => l.Link, () => null)` crashed on every health check without a Link. Switch to MatchUnsafe, the LanguageExt- sanctioned way to intentionally produce a nullable result from Option. - HealthControllerTests: add the idiomatic-route-assertion test (route template + Name) and empty-list case, matching FillerPresetControllerTests. Verified: GetAllHealthCheckResultsForApiHandler already matches the existing GetAllHealthCheckResultsHandler's cancellation handling (both swallow TaskCanceledException/OperationCanceledException), so no change was needed there. Confirmed no OpenApi contract test enumerates all endpoints for error-response metadata (it's an explicit TestCase allowlist), so the new GET needed no new entries. Co-Authored-By: Claude Fable 5 --- ErsatzTV.Application/Health/Mapper.cs | 2 +- .../Api/Health/HealthCheckResponseModel.cs | 3 ++- .../Controllers/HealthControllerTests.cs | 25 +++++++++++++++++++ ErsatzTV/Controllers/Api/HealthController.cs | 4 +-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/ErsatzTV.Application/Health/Mapper.cs b/ErsatzTV.Application/Health/Mapper.cs index 50c5fa61a..bc5bd56d8 100644 --- a/ErsatzTV.Application/Health/Mapper.cs +++ b/ErsatzTV.Application/Health/Mapper.cs @@ -10,7 +10,7 @@ internal static class Mapper result.Title, GetStatus(result.Status), result.Message, - result.Link.Match(l => l.Link, () => null)); + result.Link.MatchUnsafe(l => l.Link, () => null)); private static string GetStatus(HealthCheckStatus status) => status switch diff --git a/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs index 37be2a34c..39cf3ae39 100644 --- a/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs +++ b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs @@ -1,7 +1,8 @@ +#nullable enable namespace ErsatzTV.Core.Api.Health; public record HealthCheckResponseModel( string Title, string Status, string Detail, - string Link); + string? Link); diff --git a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs index 113732a89..ca8cbfc2c 100644 --- a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs @@ -1,7 +1,9 @@ +using System.Reflection; using ErsatzTV.Application.Health; using ErsatzTV.Controllers.Api; using ErsatzTV.Core.Api.Health; using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; using NSubstitute; using NUnit.Framework; using Shouldly; @@ -21,6 +23,18 @@ public class HealthControllerTests _controller = new HealthController(_mediator); } + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(HealthController).GetMethod(nameof(HealthController.GetAll)) + ?? throw new AssertionException("Missing action GetAll"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/health"); + attribute.Name.ShouldBe("GetHealthChecks"); + } + [Test] public async Task GetAll_Should_Return_Results_From_Mediator() { @@ -37,4 +51,15 @@ public class HealthControllerTests result.ShouldBe(expected); } + + [Test] + public async Task GetAll_Should_Return_Empty_List_When_None_Exist() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([]); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBeEmpty(); + } } diff --git a/ErsatzTV/Controllers/Api/HealthController.cs b/ErsatzTV/Controllers/Api/HealthController.cs index 3c865fd49..9398c1e03 100644 --- a/ErsatzTV/Controllers/Api/HealthController.cs +++ b/ErsatzTV/Controllers/Api/HealthController.cs @@ -6,12 +6,12 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[EndpointGroupName("general")] public class HealthController(IMediator mediator) : ControllerBase { - [HttpGet("/api/health")] + [HttpGet("/api/health", Name = "GetHealthChecks")] [Tags("Health")] [EndpointSummary("Get health check results")] + [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) => await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken); From 272174ee75171215408f3878f94239f1884f5091 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 21:15:38 +0200 Subject: [PATCH 07/19] chore(openapi): regenerate v1.json for health endpoint (#108) Co-Authored-By: Claude Fable 5 --- ErsatzTV/wwwroot/openapi/v1.json | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 3df42d662..a8e70ff8e 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -1433,6 +1433,46 @@ } } }, + "/api/health": { + "get": { + "tags": [ + "Health" + ], + "summary": "Get health check results", + "operationId": "GetHealthChecks", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HealthCheckResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HealthCheckResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HealthCheckResponseModel" + } + } + } + } + } + } + } + }, "/api/libraries/{id}/scan": { "post": { "tags": [ @@ -4349,6 +4389,32 @@ ], "type": "string" }, + "HealthCheckResponseModel": { + "required": [ + "title", + "status", + "detail", + "link" + ], + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "link": { + "type": [ + "null", + "string" + ] + } + } + }, "HlsSessionModel": { "required": [ "channelNumber", @@ -5946,6 +6012,9 @@ { "name": "Graphics Elements" }, + { + "name": "Health" + }, { "name": "Libraries" }, From 922bec4b244551597bbcfeaec954a9bb2c39731b Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 21:18:41 +0200 Subject: [PATCH 08/19] refactor(api): throw on unmapped health status instead of silent NotApplicable fallthrough (#108) Review finding: NotApplicable is filtered by the handler before mapping, so a silent ->"info" arm would only mask a missing filter if the mapper is ever reused unfiltered. Co-Authored-By: Claude Fable 5 --- ErsatzTV.Application/Health/Mapper.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ErsatzTV.Application/Health/Mapper.cs b/ErsatzTV.Application/Health/Mapper.cs index bc5bd56d8..5e4ca919b 100644 --- a/ErsatzTV.Application/Health/Mapper.cs +++ b/ErsatzTV.Application/Health/Mapper.cs @@ -19,7 +19,6 @@ internal static class Mapper HealthCheckStatus.Fail => "fail", HealthCheckStatus.Warning => "warn", HealthCheckStatus.Info => "info", - HealthCheckStatus.NotApplicable => "info", - _ => throw new ArgumentOutOfRangeException(nameof(status)) + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) }; } From 20ca71b388740db79343e9a16c6d9c7b8c4ef5de Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 22:18:19 +0200 Subject: [PATCH 09/19] feat(api): playout read endpoints + reset-all (#100 #101 #107 #110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/playouts — paged list over GetPagedPlayouts (query/pageNum/pageSize), list DTO with buildStatus (#100, #107) - GET /api/playouts/{id}/items — paged future items + UNSCHEDULED gaps over GetFuturePlayoutItemsById; 404 ProblemDetails for unknown playout (matches /api/schedules/{id}/items precedent) (#101) - buildStatus {lastBuild, success, message} on GET /api/playouts/{id} (BuildStatus now included by GetPlayoutByIdHandler) (#107) - GET /api/playouts/warnings/count — failed-build count for the warnings badge (#107) - POST /api/playouts/reset-all — 202 Accepted, wraps ResetAllPlayouts (#110) - POST /api/channels/{channelNumber}/playout/reset — optional ?mode= override; default branches by ScheduleKind (Classic→Refresh, others→Reset) to match Blazor semantics (#110) - Option→FillerKind? projection uses MatchUnsafe (Match throws on null-returning branch; idiom per Health/Mapper.cs) - Tests: controller unit tests (routes, projections, 404s, reset-mode defaults), OpenAPI ProblemDetails contract entry for /api/playouts/{id}/items get 404 - Regenerated wwwroot/openapi/v1.json Co-Authored-By: Claude Fable 5 --- .../Playouts/Queries/GetPlayoutByIdHandler.cs | 1 + .../PagedPlayoutItemsResponseModel.cs | 5 + .../Playouts/PagedPlayoutsResponseModel.cs | 5 + .../PlayoutBuildStatusResponseModel.cs | 6 + .../Api/Playouts/PlayoutItemResponseModel.cs | 10 + .../Playouts/PlayoutListItemResponseModel.cs | 13 + .../Api/Playouts/PlayoutResponseModel.cs | 14 +- .../Controllers/ChannelControllerTests.cs | 56 +- .../OpenApiErrorResponseContractTests.cs | 1 + .../Controllers/PlayoutControllerTests.cs | 137 ++++- ErsatzTV/Controllers/Api/ChannelController.cs | 31 +- ErsatzTV/Controllers/Api/PlayoutController.cs | 96 +++- ErsatzTV/wwwroot/openapi/v1.json | 495 +++++++++++++++--- 13 files changed, 792 insertions(+), 78 deletions(-) create mode 100644 ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs index e47f91433..b765ff7dd 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs @@ -16,6 +16,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory dbContextFactory .AsNoTracking() .Include(p => p.ProgramSchedule) .Include(p => p.Channel) + .Include(p => p.BuildStatus) .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken) .MapT(p => new PlayoutNameViewModel( p.Id, diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs new file mode 100644 index 000000000..be6249ef1 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.Playouts; + +public record PagedPlayoutItemsResponseModel( + int TotalCount, + List Page); diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs new file mode 100644 index 000000000..5473e67d2 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.Playouts; + +public record PagedPlayoutsResponseModel( + int TotalCount, + List Page); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs new file mode 100644 index 000000000..5410c9d23 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutBuildStatusResponseModel( + DateTimeOffset LastBuild, + bool Success, + string Message); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs new file mode 100644 index 000000000..ab9c7104e --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutItemResponseModel( + string Title, + DateTimeOffset Start, + DateTimeOffset Finish, + string Duration, + FillerKind? FillerKind); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs new file mode 100644 index 000000000..b7caa66ce --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs @@ -0,0 +1,13 @@ +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutListItemResponseModel( + int Id, + string ChannelNumber, + string ChannelName, + PlayoutScheduleKind ScheduleKind, + string ScheduleName, + TimeSpan? DailyRebuildTime, + PlayoutBuildStatusResponseModel? BuildStatus); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs index a80fd6ed0..cd382a278 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs @@ -1,3 +1,4 @@ +#nullable enable using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Api.Playouts; @@ -9,8 +10,9 @@ public record PlayoutResponseModel( string ChannelNumber, ChannelPlayoutMode PlayoutMode, string ScheduleName, - string ScheduleFile, - TimeSpan? DailyRebuildTime) + string? ScheduleFile, + TimeSpan? DailyRebuildTime, + PlayoutBuildStatusResponseModel? BuildStatus) { public static PlayoutResponseModel From( int id, @@ -19,8 +21,9 @@ public record PlayoutResponseModel( string channelNumber, ChannelPlayoutMode playoutMode, string scheduleName, - string scheduleFile, - TimeSpan? dailyRebuildTime) => + string? scheduleFile, + TimeSpan? dailyRebuildTime, + PlayoutBuildStatusResponseModel? buildStatus) => new( id, scheduleKind, @@ -29,5 +32,6 @@ public record PlayoutResponseModel( playoutMode, scheduleName, scheduleFile, - dailyRebuildTime); + dailyRebuildTime, + buildStatus); } diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index b8cf0ec1f..1a03ca2e0 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -8,6 +8,7 @@ using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Scheduling; using LanguageExt; using static LanguageExt.Prelude; using MediatR; @@ -23,15 +24,15 @@ namespace ErsatzTV.Tests.Controllers; public class ChannelControllerTests { private IMediator _mediator = null!; + private Channel _workerChannel = null!; private ChannelController _controller = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); - ChannelWriter writer = - System.Threading.Channels.Channel.CreateUnbounded().Writer; - _controller = new ChannelController(writer, _mediator); + _workerChannel = System.Threading.Channels.Channel.CreateUnbounded(); + _controller = new ChannelController(_workerChannel.Writer, _mediator); } [Test] @@ -157,7 +158,7 @@ public class ChannelControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); - IActionResult result = await _controller.ResetPlayout("404"); + IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problemDetails = notFound.Value.ShouldBeOfType(); @@ -165,6 +166,53 @@ public class ChannelControllerTests problemDetails.Title.ShouldBe("Resource not found"); } + [TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)] + [TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)] + [TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)] + public async Task ResetPlayout_Should_Default_Mode_By_ScheduleKind( + PlayoutScheduleKind scheduleKind, + PlayoutBuildMode expectedMode) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(9)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9, scheduleKind))); + + IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None); + + result.ShouldBeOfType(); + _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); + var buildPlayout = request.ShouldBeOfType(); + buildPlayout.PlayoutId.ShouldBe(9); + buildPlayout.Mode.ShouldBe(expectedMode); + } + + [Test] + public async Task ResetPlayout_Should_Honor_Explicit_Mode() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(9)); + + IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None); + + result.ShouldBeOfType(); + _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().Mode.ShouldBe(PlayoutBuildMode.Continue); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) => + new( + id, + scheduleKind, + "Channel", + "5", + ChannelPlayoutMode.Continuous, + "Schedule", + string.Empty, + null, + null); + private static ChannelViewModel MakeVm(int id) => new( id, diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 71854b956..e0222045d 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -68,6 +68,7 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/playouts", "post", "422")] [TestCase("/api/playouts/{id}", "delete", "404")] [TestCase("/api/playouts/{id}", "delete", "422")] + [TestCase("/api/playouts/{id}/items", "get", "404")] [TestCase("/api/ffmpeg/profiles/{id}", "get", "404")] [TestCase("/api/ffmpeg/profiles", "post", "404")] [TestCase("/api/ffmpeg/profiles", "post", "401")] diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 0726e9a6f..054fcdc43 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -5,6 +5,7 @@ using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Errors; using LanguageExt; using MediatR; @@ -34,8 +35,12 @@ public class PlayoutControllerTests [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { + ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/playouts"); ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}"); + ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/playouts/{id:int}/items"); + ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/playouts/warnings/count"); ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts"); + ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/playouts/reset-all"); ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}"); } @@ -162,6 +167,130 @@ public class PlayoutControllerTests problem.Title.ShouldBe("Resource not found"); } + [Test] + public async Task GetAll_Should_Project_Paged_List_With_BuildStatus() + { + var buildStatus = new PlayoutBuildStatus + { + LastBuild = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero), + Success = false, + Message = "boom" + }; + PlayoutNameViewModel vm = MakePlayout(9) with + { + BuildStatus = buildStatus, + DbDailyRebuildTime = TimeSpan.FromHours(4) + }; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutsViewModel(1, [vm])); + + PagedPlayoutsResponseModel result = await _controller.GetAll("q", 2, 25, CancellationToken.None); + + result.TotalCount.ShouldBe(1); + PlayoutListItemResponseModel item = result.Page.Single(); + item.Id.ShouldBe(9); + item.ChannelNumber.ShouldBe("101"); + item.ChannelName.ShouldBe("Channel"); + item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic); + item.ScheduleName.ShouldBe("Schedule"); + item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4)); + item.BuildStatus.ShouldNotBeNull(); + item.BuildStatus.Success.ShouldBeFalse(); + item.BuildStatus.Message.ShouldBe("boom"); + item.BuildStatus.LastBuild.ShouldBe(buildStatus.LastBuild); + + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25), + Arg.Any()); + } + + [Test] + public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent() + { + PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null }; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutsViewModel(1, [vm])); + + PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); + + result.Page.Single().BuildStatus.ShouldBeNull(); + } + + [Test] + public async Task GetItems_Should_Project_Items_And_Null_FillerKind_For_Gaps() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + var item = new PlayoutItemViewModel( + "Movie", + new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), + "1:00:00", + string.Empty, + Some(FillerKind.MidRoll)); + var gap = new PlayoutItemViewModel( + "UNSCHEDULED", + new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero), + "30:00", + string.Empty, + Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutItemsViewModel(2, [item, gap])); + + IActionResult actionResult = await _controller.GetItems(9, showFiller: true, 1, 10, CancellationToken.None); + + var result = actionResult.ShouldBeOfType().Value.ShouldBeOfType(); + result.TotalCount.ShouldBe(2); + result.Page[0].Title.ShouldBe("Movie"); + result.Page[0].Duration.ShouldBe("1:00:00"); + result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll); + result.Page[1].Title.ShouldBe("UNSCHEDULED"); + result.Page[1].FillerKind.ShouldBeNull(); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10), + Arg.Any()); + } + + [Test] + public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetItems(404, showFiller: false, 0, 100, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetWarningsCount_Should_Return_Count() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(7); + + int result = await _controller.GetWarningsCount(CancellationToken.None); + + result.ShouldBe(7); + } + + [Test] + public async Task ResetAll_Should_Return_202_And_Send_Command() + { + IActionResult result = await _controller.ResetAll(CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + } + private static PlayoutNameViewModel MakePlayout(int id) => new( id, @@ -183,7 +312,13 @@ public class PlayoutControllerTests vm.PlayoutMode, vm.ScheduleName, vm.ScheduleFile, - vm.DbDailyRebuildTime); + vm.DbDailyRebuildTime, + vm.BuildStatus is null + ? null + : new PlayoutBuildStatusResponseModel( + vm.BuildStatus.LastBuild, + vm.BuildStatus.Success, + vm.BuildStatus.Message)); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 6220adfa5..76577ca10 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling; using ErsatzTV.Extensions; using MediatR; @@ -90,18 +91,42 @@ public class ChannelController(ChannelWriter workerCh [HttpPost("/api/channels/{channelNumber}/playout/reset")] [Tags("Channels")] [EndpointSummary("Reset channel playout")] + [EndpointDescription( + "When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " + + "progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " + + "Pass mode to force a specific PlayoutBuildMode.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] - public async Task ResetPlayout(string channelNumber) + public async Task ResetPlayout( + string channelNumber, + [FromQuery] PlayoutBuildMode? mode, + CancellationToken cancellationToken) { - Option maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber)); + Option maybePlayoutId = + await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken); foreach (int playoutId in maybePlayoutId) { - await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset)); + PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken); + await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken); return new OkResult(); } return ApiResults.NotFoundProblem(); } + + // Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress), + // every other kind resets from scratch. + private async Task DefaultResetMode(int playoutId, CancellationToken cancellationToken) + { + Option maybePlayout = + await mediator.Send(new GetPlayoutById(playoutId), cancellationToken); + return maybePlayout.Match( + Some: vm => vm.ScheduleKind switch + { + PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh, + _ => PlayoutBuildMode.Reset + }, + None: () => PlayoutBuildMode.Reset); + } } diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 833ce778b..82d3c7889 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -3,6 +3,8 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -13,6 +15,32 @@ namespace ErsatzTV.Controllers.Api; [ApiController] public class PlayoutController(IMediator mediator) : ControllerBase { + [HttpGet("/api/playouts", Name = "GetPlayouts")] + [Tags("Playouts")] + [EndpointSummary("List playouts")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)] + public async Task GetAll( + [FromQuery] string query = "", + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + PagedPlayoutsViewModel result = + await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken); + return new PagedPlayoutsResponseModel( + result.TotalCount, + result.Page.Map(ToListItemResponse).ToList()); + } + + [HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")] + [Tags("Playouts")] + [EndpointSummary("Count playouts with a failed build")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + public async Task GetWarningsCount(CancellationToken cancellationToken) => + await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken); + [HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")] [Tags("Playouts")] [EndpointSummary("Get a playout by id")] @@ -25,6 +53,34 @@ public class PlayoutController(IMediator mediator) : ControllerBase return result.Map(ToResponse).ToGetResult(); } + [HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")] + [Tags("Playouts")] + [EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetItems( + int id, + [FromQuery] bool showFiller = false, + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + PagedPlayoutItemsViewModel result = await mediator.Send( + new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize), + cancellationToken); + return new OkObjectResult( + new PagedPlayoutItemsResponseModel( + result.TotalCount, + result.Page.Map(ToItemResponse).ToList())); + } + [HttpPost("/api/playouts")] [Tags("Playouts")] [EndpointSummary("Create a classic playout")] @@ -49,6 +105,17 @@ public class PlayoutController(IMediator mediator) : ControllerBase }); } + [HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")] + [Tags("Playouts")] + [EndpointSummary("Reset all playouts")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public async Task ResetAll(CancellationToken cancellationToken) + { + await mediator.Send(new ResetAllPlayouts(), cancellationToken); + return Accepted(); + } + [HttpDelete("/api/playouts/{id:int}")] [Tags("Playouts")] [EndpointSummary("Delete a playout")] @@ -71,5 +138,32 @@ public class PlayoutController(IMediator mediator) : ControllerBase vm.PlayoutMode, vm.ScheduleName, vm.ScheduleFile, - vm.DbDailyRebuildTime); + vm.DbDailyRebuildTime, + ToBuildStatus(vm.BuildStatus)); + + private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) => + new( + vm.PlayoutId, + vm.ChannelNumber, + vm.ChannelName, + vm.ScheduleKind, + vm.ScheduleName, + vm.DbDailyRebuildTime, + ToBuildStatus(vm.BuildStatus)); + + private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) => + buildStatus is null + ? null + : new PlayoutBuildStatusResponseModel( + buildStatus.LastBuild, + buildStatus.Success, + buildStatus.Message); + + private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) => + new( + vm.Title, + vm.Start, + vm.Finish, + vm.Duration, + vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null)); } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index a8e70ff8e..9b201c81f 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -370,6 +370,7 @@ "Channels" ], "summary": "Reset channel playout", + "description": "When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. Pass mode to force a specific PlayoutBuildMode.", "parameters": [ { "name": "channelNumber", @@ -378,6 +379,13 @@ "schema": { "type": "string" } + }, + { + "name": "mode", + "in": "query", + "schema": { + "$ref": "#/components/schemas/PlayoutBuildMode" + } } ], "responses": { @@ -1595,6 +1603,192 @@ } } }, + "/api/playouts": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "List playouts", + "operationId": "GetPlayouts", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedPlayoutsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedPlayoutsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedPlayoutsResponseModel" + } + } + } + } + } + }, + "post": { + "tags": [ + "Playouts" + ], + "summary": "Create a classic playout", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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/playouts/warnings/count": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Count playouts with a failed build", + "operationId": "GetPlayoutWarningsCount", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "integer", + "format": "int32" + } + }, + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + }, + "text/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, "/api/playouts/{id}": { "get": { "tags": [ @@ -1719,54 +1913,67 @@ } } }, - "/api/playouts": { - "post": { + "/api/playouts/{id}/items": { + "get": { "tags": [ "Playouts" ], - "summary": "Create a classic playout", - "requestBody": { - "content": { - "application/json-patch+json": { - "schema": { - "$ref": "#/components/schemas/CreatePlayoutRequest" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreatePlayoutRequest" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreatePlayoutRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreatePlayoutRequest" - } + "summary": "Get upcoming items (and unscheduled gaps) for a playout", + "operationId": "GetPlayoutItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" } }, - "required": true - }, + { + "name": "showFiller", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/PlayoutResponseModel" + "$ref": "#/components/schemas/PagedPlayoutItemsResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/PlayoutResponseModel" + "$ref": "#/components/schemas/PagedPlayoutItemsResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/PlayoutResponseModel" + "$ref": "#/components/schemas/PagedPlayoutItemsResponseModel" } } } @@ -1790,26 +1997,20 @@ } } } - }, - "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/playouts/reset-all": { + "post": { + "tags": [ + "Playouts" + ], + "summary": "Reset all playouts", + "operationId": "ResetAllPlayouts", + "responses": { + "202": { + "description": "Accepted" } } } @@ -4650,6 +4851,50 @@ ], "type": "string" }, + "PagedPlayoutItemsResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/PlayoutItemResponseModel" + } + } + } + }, + "PagedPlayoutsResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/PlayoutListItemResponseModel" + } + } + } + }, "PlaybackOrder": { "enum": [ "None", @@ -4692,6 +4937,126 @@ } } }, + "PlayoutBuildMode": { + "enum": [ + "Continue", + "Refresh", + "Reset" + ], + "type": "string" + }, + "PlayoutBuildStatusResponseModel": { + "required": [ + "lastBuild", + "success", + "message" + ], + "type": "object", + "properties": { + "lastBuild": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean" + }, + "message": { + "type": [ + "null", + "string" + ] + } + } + }, + "PlayoutItemResponseModel": { + "required": [ + "title", + "start", + "finish", + "duration", + "fillerKind" + ], + "type": "object", + "properties": { + "title": { + "type": [ + "null", + "string" + ] + }, + "start": { + "type": "string", + "format": "date-time" + }, + "finish": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": [ + "null", + "string" + ] + }, + "fillerKind": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FillerKind" + } + ] + } + } + }, + "PlayoutListItemResponseModel": { + "required": [ + "id", + "channelNumber", + "channelName", + "scheduleKind", + "scheduleName", + "dailyRebuildTime", + "buildStatus" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "channelNumber": { + "type": "string" + }, + "channelName": { + "type": "string" + }, + "scheduleKind": { + "$ref": "#/components/schemas/PlayoutScheduleKind" + }, + "scheduleName": { + "type": "string" + }, + "dailyRebuildTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "buildStatus": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PlayoutBuildStatusResponseModel" + } + ] + } + } + }, "PlayoutMode": { "enum": [ "Flood", @@ -4710,7 +5075,8 @@ "playoutMode", "scheduleName", "scheduleFile", - "dailyRebuildTime" + "dailyRebuildTime", + "buildStatus" ], "type": "object", "properties": { @@ -4722,25 +5088,16 @@ "$ref": "#/components/schemas/PlayoutScheduleKind" }, "channelName": { - "type": [ - "null", - "string" - ] + "type": "string" }, "channelNumber": { - "type": [ - "null", - "string" - ] + "type": "string" }, "playoutMode": { "$ref": "#/components/schemas/ChannelPlayoutMode" }, "scheduleName": { - "type": [ - "null", - "string" - ] + "type": "string" }, "scheduleFile": { "type": [ @@ -4754,6 +5111,16 @@ "null", "string" ] + }, + "buildStatus": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PlayoutBuildStatusResponseModel" + } + ] } } }, From 1b7b0e549a81d068fe9ba38d2bbebe9dc3407763 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:38 +0200 Subject: [PATCH 10/19] feat(api): add media sources and scan status endpoints (#103 #106) --- .../Libraries/Queries/GetLibraryScanStatus.cs | 5 + .../Queries/GetLibraryScanStatusHandler.cs | 20 ++ .../Queries/GetAllMediaSourcesForApi.cs | 5 + .../GetAllMediaSourcesForApiHandler.cs | 148 +++++++++++++++ .../LibraryScanStatusResponseModel.cs | 3 + .../MediaSourceLibraryResponseModel.cs | 11 ++ .../MediaSources/MediaSourceResponseModel.cs | 9 + .../Metadata/IScannerProxyService.cs | 3 + ErsatzTV.Core/Metadata/ScannerProxyService.cs | 3 + .../GetLibraryScanStatusHandlerTests.cs | 32 ++++ .../GetAllMediaSourcesForApiHandlerTests.cs | 179 ++++++++++++++++++ .../Controllers/LibrariesControllerTests.cs | 55 ++++++ .../MediaSourcesControllerTests.cs | 59 ++++++ .../Controllers/Api/LibrariesController.cs | 11 +- .../Controllers/Api/MediaSourcesController.cs | 19 ++ 15 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs create mode 100644 ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs create mode 100644 ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs create mode 100644 ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs create mode 100644 ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs create mode 100644 ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/MediaSourcesController.cs diff --git a/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs new file mode 100644 index 000000000..9d21ed9aa --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Libraries; + +namespace ErsatzTV.Application.Libraries; + +public record GetLibraryScanStatus : IRequest>; diff --git a/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs new file mode 100644 index 000000000..707d32cf3 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs @@ -0,0 +1,20 @@ +using ErsatzTV.Core.Api.Libraries; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Metadata; + +namespace ErsatzTV.Application.Libraries; + +public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService) + : IRequestHandler> +{ + public Task> Handle( + GetLibraryScanStatus request, + CancellationToken cancellationToken) + { + List result = scannerProxyService.GetActiveScans() + .Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress)) + .ToList(); + + return Task.FromResult(result); + } +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs new file mode 100644 index 000000000..8753aa9ab --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.MediaSources; + +namespace ErsatzTV.Application.MediaSources; + +public record GetAllMediaSourcesForApi : IRequest>; diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs new file mode 100644 index 000000000..13e007856 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs @@ -0,0 +1,148 @@ +#nullable enable +using Dapper; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.MediaSources; + +public class GetAllMediaSourcesForApiHandler( + IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetAllMediaSourcesForApi request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + List mediaSources = await dbContext.MediaSources + .AsNoTracking() + .Include(s => s.Libraries) + .ThenInclude(l => l.Paths) + .ToListAsync(cancellationToken); + + Dictionary itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken); + Dictionary addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken); + + var result = new List(); + foreach (MediaSource mediaSource in mediaSources) + { + List libraryModels = mediaSource.Libraries + .Filter(ShouldIncludeLibrary) + .OrderBy(l => l.MediaKind) + .ThenBy(l => l.Name) + .Map(l => new MediaSourceLibraryResponseModel( + l.Id, + l.Name, + l.MediaKind, + l.LastScan, + itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0)) + .ToList(); + + string? address = addressByMediaSourceId.TryGetValue(mediaSource.Id, out string? a) ? a : null; + + result.Add( + new MediaSourceResponseModel( + mediaSource.Id, + GetKind(mediaSource), + GetName(mediaSource), + address, + libraryModels)); + } + + return result + .OrderBy(s => s.Kind == "Local" ? 0 : 1) + .ThenBy(s => s.Kind) + .ThenBy(s => s.Name) + .ToList(); + } + + private static async Task> GetItemCountsByLibrary( + TvContext dbContext, + CancellationToken cancellationToken) + { + IEnumerable counts = await dbContext.Connection.QueryAsync( + new CommandDefinition( + @"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count + FROM MediaItem + INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id + GROUP BY LP.LibraryId", + cancellationToken: cancellationToken)); + + return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count); + } + + private static async Task> GetConnectionAddresses( + TvContext dbContext, + CancellationToken cancellationToken) + { + var addresses = new Dictionary(); + + foreach (PlexMediaSource plex in await dbContext.PlexMediaSources + .AsNoTracking() + .Include(s => s.Connections) + .ToListAsync(cancellationToken)) + { + foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive))) + { + addresses[plex.Id] = connection.Uri; + } + } + + foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources + .AsNoTracking() + .Include(s => s.Connections) + .ToListAsync(cancellationToken)) + { + foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone()) + { + addresses[jellyfin.Id] = connection.Address; + } + } + + foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources + .AsNoTracking() + .Include(s => s.Connections) + .ToListAsync(cancellationToken)) + { + foreach (EmbyConnection connection in emby.Connections.HeadOrNone()) + { + addresses[emby.Id] = connection.Address; + } + } + + return addresses; + } + + private static bool ShouldIncludeLibrary(Library library) => + library switch + { + LocalLibrary => library.Paths.Count > 0, + PlexLibrary plex => plex.ShouldSyncItems, + JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems, + EmbyLibrary emby => emby.ShouldSyncItems, + _ => false + }; + + private static string GetKind(MediaSource mediaSource) => + mediaSource switch + { + PlexMediaSource => "Plex", + JellyfinMediaSource => "Jellyfin", + EmbyMediaSource => "Emby", + _ => "Local" + }; + + private static string GetName(MediaSource mediaSource) => + mediaSource switch + { + PlexMediaSource plex => plex.ServerName, + JellyfinMediaSource jellyfin => jellyfin.ServerName, + EmbyMediaSource emby => emby.ServerName, + _ => "Local" + }; + + private sealed record LibraryItemCount(long LibraryId, long Count); +} diff --git a/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs new file mode 100644 index 000000000..fec106914 --- /dev/null +++ b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.Libraries; + +public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent); diff --git a/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs new file mode 100644 index 000000000..967853efd --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs @@ -0,0 +1,11 @@ +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record MediaSourceLibraryResponseModel( + int Id, + string Name, + LibraryMediaKind MediaKind, + DateTime? LastScan, + int ItemCount); diff --git a/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs new file mode 100644 index 000000000..2ee99eb43 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace ErsatzTV.Core.Api.MediaSources; + +public record MediaSourceResponseModel( + int Id, + string Kind, + string Name, + string? ConnectionAddress, + List Libraries); diff --git a/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs b/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs index 556f46720..040841d1b 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs @@ -1,3 +1,5 @@ +using ErsatzTV.Core.Metadata; + namespace ErsatzTV.Core.Interfaces.Metadata; public interface IScannerProxyService @@ -7,4 +9,5 @@ public interface IScannerProxyService Task Progress(Guid scanId, decimal percentComplete); bool IsActive(Guid scanId); Option GetProgress(int libraryId); + IReadOnlyList GetActiveScans(); } diff --git a/ErsatzTV.Core/Metadata/ScannerProxyService.cs b/ErsatzTV.Core/Metadata/ScannerProxyService.cs index 561116132..8b6b4fed6 100644 --- a/ErsatzTV.Core/Metadata/ScannerProxyService.cs +++ b/ErsatzTV.Core/Metadata/ScannerProxyService.cs @@ -49,4 +49,7 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService public Option GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress) ? progress : Option.None; + + public IReadOnlyList GetActiveScans() => + _activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList(); } diff --git a/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs b/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs new file mode 100644 index 000000000..1e062f32d --- /dev/null +++ b/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs @@ -0,0 +1,32 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Api.Libraries; +using ErsatzTV.Core.Metadata; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Libraries; + +[TestFixture] +public class GetLibraryScanStatusHandlerTests +{ + [Test] + public async Task Handle_Should_Return_Active_Scans() + { + var mediator = Substitute.For(); + var scannerProxyService = new ScannerProxyService(mediator); + Guid scanId = scannerProxyService.StartScan(42) + .Match( + Some: id => id, + None: () => throw new AssertionException("Expected scan to start")); + await scannerProxyService.Progress(scanId, 62.5m); + + var handler = new GetLibraryScanStatusHandler(scannerProxyService); + + List result = + await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None); + + result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]); + } +} diff --git a/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs b/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs new file mode 100644 index 000000000..85f421a53 --- /dev/null +++ b/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs @@ -0,0 +1,179 @@ +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.MediaSources; + +[TestFixture] +public class GetAllMediaSourcesForApiHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Handle_Should_Group_Configured_Libraries_By_Source_With_Item_Counts() + { + await SeedMediaSources(); + var handler = new GetAllMediaSourcesForApiHandler(_db.Factory); + + var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None); + + result.Count.ShouldBe(4); + + result[0].Kind.ShouldBe("Local"); + result[0].Name.ShouldBe("Local"); + result[0].ConnectionAddress.ShouldBeNull(); + result[0].Libraries.Single().Name.ShouldBe("Local Movies"); + result[0].Libraries.Single().LastScan.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)); + result[0].Libraries.Single().ItemCount.ShouldBe(2); + + result[1].Kind.ShouldBe("Emby"); + result[1].Name.ShouldBe("Emby Server"); + result[1].ConnectionAddress.ShouldBe("http://emby.local"); + result[1].Libraries.Single().Name.ShouldBe("Emby Shows"); + result[1].Libraries.Single().ItemCount.ShouldBe(1); + + result[2].Kind.ShouldBe("Jellyfin"); + result[2].Name.ShouldBe("Jellyfin Server"); + result[2].ConnectionAddress.ShouldBe("http://jellyfin.local"); + result[2].Libraries.ShouldBeEmpty(); + + result[3].Kind.ShouldBe("Plex"); + result[3].Name.ShouldBe("Plex Server"); + result[3].ConnectionAddress.ShouldBe("http://plex.local"); + result[3].Libraries.Single().Name.ShouldBe("Plex Movies"); + result[3].Libraries.Single().ItemCount.ShouldBe(0); + } + + [Test] + public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries() + { + await SeedMediaSources(); + var handler = new GetAllMediaSourcesForApiHandler(_db.Factory); + + var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None); + + result.SelectMany(s => s.Libraries).Select(l => l.Name) + .ShouldNotContain("Empty Local"); + result.SelectMany(s => s.Libraries).Select(l => l.Name) + .ShouldNotContain("Disabled Jellyfin"); + } + + private async Task SeedMediaSources() + { + await using TvContext context = _db.CreateContext(); + + var localSource = new LocalMediaSource + { + Libraries = + [ + new LocalLibrary + { + Name = "Local Movies", + MediaKind = LibraryMediaKind.Movies, + LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + Paths = [MakePath("/media/movies", 2)] + }, + new LocalLibrary + { + Name = "Empty Local", + MediaKind = LibraryMediaKind.Shows, + Paths = [] + } + ] + }; + + var plexSource = new PlexMediaSource + { + ServerName = "Plex Server", + ProductVersion = "1", + Platform = "Linux", + PlatformVersion = "1", + ClientIdentifier = "plex", + Connections = [new PlexConnection { IsActive = true, Uri = "http://plex.local" }], + PathReplacements = [], + Libraries = + [ + new PlexLibrary + { + Name = "Plex Movies", + MediaKind = LibraryMediaKind.Movies, + Key = "1", + ShouldSyncItems = true, + Paths = [] + } + ] + }; + + var embySource = new EmbyMediaSource + { + ServerName = "Emby Server", + OperatingSystem = "Linux", + Connections = [new EmbyConnection { Address = "http://emby.local" }], + PathReplacements = [], + Libraries = + [ + new EmbyLibrary + { + Name = "Emby Shows", + MediaKind = LibraryMediaKind.Shows, + ItemId = "emby-shows", + ShouldSyncItems = true, + PathInfos = [], + Paths = [MakePath("/emby/shows", 1)] + } + ] + }; + + var jellyfinSource = new JellyfinMediaSource + { + ServerName = "Jellyfin Server", + OperatingSystem = "Linux", + Connections = [new JellyfinConnection { Address = "http://jellyfin.local" }], + PathReplacements = [], + Libraries = + [ + new JellyfinLibrary + { + Name = "Disabled Jellyfin", + MediaKind = LibraryMediaKind.Movies, + ItemId = "jellyfin-movies", + ShouldSyncItems = false, + PathInfos = [], + Paths = [MakePath("/jellyfin/movies", 1)] + } + ] + }; + + context.MediaSources.AddRange(localSource, plexSource, embySource, jellyfinSource); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + } + + private static LibraryPath MakePath(string path, int mediaItemCount) => + new() + { + Path = path, + LibraryFolders = [], + MediaItems = Enumerable.Range(0, mediaItemCount) + .Select(_ => new Movie + { + MovieMetadata = [], + MediaVersions = [], + Collections = [], + CollectionItems = [], + TraktListItems = [] + }) + .Cast() + .ToList() + }; +} diff --git a/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs new file mode 100644 index 000000000..95d67c9a0 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using ErsatzTV.Application.Libraries; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Libraries; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class LibrariesControllerTests +{ + private LibrariesController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new LibrariesController(Substitute.For(), _mediator); + } + + [Test] + public void ScanStatus_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(LibrariesController).GetMethod(nameof(LibrariesController.GetScanStatus)) + ?? throw new AssertionException("Missing action GetScanStatus"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/libraries/scan-status"); + attribute.Name.ShouldBe("GetLibraryScanStatus"); + } + + [Test] + public async Task GetScanStatus_Should_Return_Results_From_Mediator() + { + var expected = new List + { + new(1, 42.5m), + new(2, 99m) + }; + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(expected); + + List result = await _controller.GetScanStatus(CancellationToken.None); + + result.ShouldBe(expected); + } +} diff --git a/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs new file mode 100644 index 000000000..3e2c98b08 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class MediaSourcesControllerTests +{ + private MediaSourcesController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new MediaSourcesController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(MediaSourcesController).GetMethod(nameof(MediaSourcesController.GetAll)) + ?? throw new AssertionException("Missing action GetAll"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/media-sources"); + attribute.Name.ShouldBe("GetMediaSources"); + } + + [Test] + public async Task GetAll_Should_Return_Results_From_Mediator() + { + var expected = new List + { + new( + 1, + "Local", + "Local", + null, + [new MediaSourceLibraryResponseModel(10, "Movies", LibraryMediaKind.Movies, null, 3)]) + }; + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(expected); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(expected); + } +} diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index efeadd1ae..15878394b 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -1,14 +1,23 @@ using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Api.Libraries; using ErsatzTV.Core.Interfaces.Repositories; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] -public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) +public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase { + [HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")] + [Tags("Libraries")] + [EndpointSummary("Get active library scan status")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetScanStatus(CancellationToken cancellationToken) => + await mediator.Send(new GetLibraryScanStatus(), cancellationToken); + [HttpPost("/api/libraries/{id:int}/scan")] [Tags("Libraries")] [EndpointSummary("Scan library")] diff --git a/ErsatzTV/Controllers/Api/MediaSourcesController.cs b/ErsatzTV/Controllers/Api/MediaSourcesController.cs new file mode 100644 index 000000000..ca774dc1e --- /dev/null +++ b/ErsatzTV/Controllers/Api/MediaSourcesController.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Core.Api.MediaSources; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class MediaSourcesController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/media-sources", Name = "GetMediaSources")] + [Tags("Media Sources")] + [EndpointSummary("Get all media sources with their libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken); +} From 69486ab2d69feeb1030e7d0583c42c0dd4b0a871 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 23:03:05 +0200 Subject: [PATCH 11/19] chore(openapi): regenerate v1 for media sources api (#103 #106) --- ErsatzTV/wwwroot/openapi/v1.json | 179 +++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 9b201c81f..6f4a48121 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -1481,6 +1481,46 @@ } } }, + "/api/libraries/scan-status": { + "get": { + "tags": [ + "Libraries" + ], + "summary": "Get active library scan status", + "operationId": "GetLibraryScanStatus", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryScanStatusResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryScanStatusResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryScanStatusResponseModel" + } + } + } + } + } + } + } + }, "/api/libraries/{id}/scan": { "post": { "tags": [ @@ -1603,6 +1643,46 @@ } } }, + "/api/media-sources": { + "get": { + "tags": [ + "Media Sources" + ], + "summary": "Get all media sources with their libraries", + "operationId": "GetMediaSources", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSourceResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSourceResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSourceResponseModel" + } + } + } + } + } + } + } + }, "/api/playouts": { "get": { "tags": [ @@ -4647,6 +4727,35 @@ } } }, + "LibraryMediaKind": { + "enum": [ + "Movies", + "Shows", + "MusicVideos", + "OtherVideos", + "Songs", + "Images", + "RemoteStreams" + ], + "type": "string" + }, + "LibraryScanStatusResponseModel": { + "required": [ + "libraryId", + "percent" + ], + "type": "object", + "properties": { + "libraryId": { + "type": "integer", + "format": "int32" + }, + "percent": { + "type": "number", + "format": "double" + } + } + }, "MarathonGroupBy": { "enum": [ "None", @@ -4729,6 +4838,73 @@ ], "type": "string" }, + "MediaSourceLibraryResponseModel": { + "required": [ + "id", + "name", + "mediaKind", + "lastScan", + "itemCount" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "mediaKind": { + "$ref": "#/components/schemas/LibraryMediaKind" + }, + "lastScan": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "itemCount": { + "type": "integer", + "format": "int32" + } + } + }, + "MediaSourceResponseModel": { + "required": [ + "id", + "kind", + "name", + "connectionAddress", + "libraries" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "connectionAddress": { + "type": [ + "null", + "string" + ] + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaSourceLibraryResponseModel" + } + } + } + }, "MultiCollectionItemViewModel": { "required": [ "multiCollectionId", @@ -6388,6 +6564,9 @@ { "name": "Maintenance" }, + { + "name": "Media Sources" + }, { "name": "Playouts" }, From 3d086aabc1b9d21ae097d880e4233c25f9bfc1cf Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:39 +0200 Subject: [PATCH 12/19] 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 --- .../Artworks/Commands/UploadArtwork.cs | 13 ++ .../Artworks/Commands/UploadArtworkHandler.cs | 53 ++++++ .../Api/Artwork/ArtworkUploadResponseModel.cs | 10 ++ .../Artworks/UploadArtworkHandlerTests.cs | 87 ++++++++++ .../ArtworkUploadControllerTests.cs | 164 ++++++++++++++++++ .../OpenApiErrorResponseContractTests.cs | 1 + .../Api/ArtworkUploadController.cs | 85 +++++++++ ErsatzTV/wwwroot/openapi/v1.json | 101 +++++++++++ 8 files changed, 514 insertions(+) create mode 100644 ErsatzTV.Application/Artworks/Commands/UploadArtwork.cs create mode 100644 ErsatzTV.Application/Artworks/Commands/UploadArtworkHandler.cs create mode 100644 ErsatzTV.Core/Api/Artwork/ArtworkUploadResponseModel.cs create mode 100644 ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/ArtworkUploadController.cs 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" }, From d0652d4adbf74c65efbd121f8e550a8cda6eafd3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:41 +0200 Subject: [PATCH 13/19] wip: partial implementation salvaged from interrupted workflow run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untrusted draft — no build/test had run yet. Review before building on it. Co-Authored-By: Claude Fable 5 --- .../ProgramScheduleItemViewModel.cs | 29 ++++++++ ...ogramScheduleItemsWithDurationViewModel.cs | 19 +++++ .../GetProgramScheduleItemsWithDurations.cs | 4 ++ ...rogramScheduleItemsWithDurationsHandler.cs | 69 +++++++++++++++++++ .../ScheduleItemDurationEstimator.cs | 68 ++++++++++++++++++ .../Controllers/Api/ScheduleController.cs | 10 ++- 6 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs index bbd48faa0..cade2456e 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -44,6 +44,35 @@ public abstract record ProgramScheduleItemViewModel( string PreferredSubtitleLanguageCode, ChannelSubtitleMode? SubtitleMode) { + /// + /// A rough estimate, in wall-clock time, of how long a single pass of this schedule item will play, + /// derived from the aggregated runtimes (MediaVersion.Duration) of the referenced content. + /// + /// Semantics by : + /// + /// One — the average runtime of one item in the referenced collection. + /// + /// Multiple — the average item runtime multiplied by the configured count + /// (), or the whole collection runtime for + /// . + /// + /// Flood — always null: a flood item fills the remaining time and is unbounded. + /// + /// Duration — always null: its runtime is the explicit + /// playoutDuration setting already present on the item, so it is not re-derived here. + /// + /// + /// + /// + /// null whenever a bounded estimate cannot be produced — an unbounded mode (Flood/Duration), + /// a referenced collection with no items that have a known non-zero duration, a Multiple mode other + /// than Count/CollectionSize, or a collection type other than + /// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass). + /// Callers should treat a null as "unknown", never as zero. + /// + /// + public TimeSpan? DurationEstimate { get; init; } + public string Name => CollectionType switch { CollectionType.Collection => Collection?.Name, diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs new file mode 100644 index 000000000..305cbfb1f --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs @@ -0,0 +1,19 @@ +namespace ErsatzTV.Application.ProgramSchedules; + +/// +/// The items of a schedule together with computed runtime estimates. Each item carries its own +/// (nullable — see that property for +/// the per-mode semantics), and is the sum of the items that +/// could be estimated. +/// +/// The schedule items, each with a nullable DurationEstimate. +/// +/// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the +/// estimable items. null when no item in the schedule could be estimated (for example a +/// schedule made up entirely of Flood/Duration items, or of collection types that are not +/// aggregated). Because unbounded items contribute nothing, this is a lower bound, never an exact +/// schedule length. +/// +public record ProgramScheduleItemsWithDurationViewModel( + List Items, + TimeSpan? TotalDurationEstimate); diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs new file mode 100644 index 000000000..abfa27406 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.ProgramSchedules; + +public record GetProgramScheduleItemsWithDurations(int Id) + : IRequest; diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs new file mode 100644 index 000000000..7e2563dd2 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs @@ -0,0 +1,69 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Repositories; +using static ErsatzTV.Application.ProgramSchedules.ScheduleItemDurationEstimator; + +namespace ErsatzTV.Application.ProgramSchedules; + +public class GetProgramScheduleItemsWithDurationsHandler( + IMediator mediator, + IMediaCollectionRepository mediaCollectionRepository) + : IRequestHandler +{ + public async Task Handle( + GetProgramScheduleItemsWithDurations request, + CancellationToken cancellationToken) + { + List items = + await mediator.Send(new GetProgramScheduleItems(request.Id), cancellationToken); + + Dictionary durationsByCollectionId = + await AggregateReferencedCollections(items); + + var itemsWithEstimates = items + .Map(item => item with { DurationEstimate = Estimate(item, durationsByCollectionId) }) + .ToList(); + + List estimates = itemsWithEstimates + .Map(item => Optional(item.DurationEstimate)) + .Somes() + .ToList(); + + TimeSpan? total = estimates.Count > 0 + ? TimeSpan.FromTicks(estimates.Sum(estimate => estimate.Ticks)) + : null; + + return new ProgramScheduleItemsWithDurationViewModel(itemsWithEstimates, total); + } + + // Aggregate MediaVersion.Duration once per distinct referenced collection (not per item). Only plain + // collections are resolved here; other reference types are estimated as null (see DurationEstimate docs). + private async Task> AggregateReferencedCollections( + IReadOnlyList items) + { + List collectionIds = items + .Filter(item => item.CollectionType is CollectionType.Collection && item.Collection is not null) + .Filter(item => item.PlayoutMode is PlayoutMode.One or PlayoutMode.Multiple) + .Map(item => item.Collection.Id) + .Distinct() + .ToList(); + + var result = new Dictionary(); + foreach (int collectionId in collectionIds) + { + List mediaItems = await mediaCollectionRepository.GetItems(collectionId); + + List durations = mediaItems + .Bind(mediaItem => mediaItem.GetNonZeroDuration()) + .ToList(); + + if (durations.Count > 0) + { + var total = TimeSpan.FromTicks(durations.Sum(duration => duration.Ticks)); + result[collectionId] = new CollectionDuration(total, durations.Count); + } + } + + return result; + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs new file mode 100644 index 000000000..05aeced01 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules; + +/// +/// Pure computation of per-item runtime estimates from pre-aggregated collection durations. +/// Kept separate from the query handler so the (non-trivial) per-mode math can be unit-tested +/// without a database. See for the +/// documented semantics this implements. +/// +internal static class ScheduleItemDurationEstimator +{ + /// + /// Aggregate runtime of a single referenced collection: the total runtime of every item with a + /// known non-zero duration, and how many such items there are. + /// + public sealed record CollectionDuration(TimeSpan Total, int ItemCount) + { + public TimeSpan? Average => ItemCount > 0 ? Total / ItemCount : null; + } + + /// + /// Estimate the runtime of one pass of , or null when no bounded + /// estimate is possible. holds aggregates only for the + /// plain collections that were resolved; a missing entry yields null. + /// + public static TimeSpan? Estimate( + ProgramScheduleItemViewModel item, + IReadOnlyDictionary durationsByCollectionId) + { + // only plain collections are aggregated in this pass + if (item.CollectionType is not CollectionType.Collection || item.Collection is null) + { + return null; + } + + if (!durationsByCollectionId.TryGetValue(item.Collection.Id, out CollectionDuration duration)) + { + return null; + } + + return item switch + { + // one item per pass -> the average item runtime + ProgramScheduleItemOneViewModel => duration.Average, + + // a fixed count of items, or the whole collection once + ProgramScheduleItemMultipleViewModel multiple => EstimateMultiple(multiple, duration), + + // Flood (unbounded fill) and Duration (bounded by its own playoutDuration setting) are not derived here + _ => null + }; + } + + private static TimeSpan? EstimateMultiple( + ProgramScheduleItemMultipleViewModel multiple, + CollectionDuration duration) => + multiple.MultipleMode switch + { + MultipleMode.Count when + int.TryParse(multiple.Count, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) + && count > 0 + && duration.Average is { } average => average * count, + MultipleMode.CollectionSize => duration.Total, + _ => null + }; +} diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs index 7592d3562..2a8d6f5ac 100644 --- a/ErsatzTV/Controllers/Api/ScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -99,8 +99,12 @@ public class ScheduleController(IMediator mediator) : ControllerBase [HttpGet("/api/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Get schedule items")] + [EndpointDescription( + "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " + + "nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " + + "derived from referenced collection/media runtimes and are null when unbounded or unknown.")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { @@ -110,8 +114,8 @@ public class ScheduleController(IMediator mediator) : ControllerBase return ApiResults.NotFoundProblem(); } - List items = - await mediator.Send(new GetProgramScheduleItems(id), cancellationToken); + ProgramScheduleItemsWithDurationViewModel items = + await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken); return new OkObjectResult(items); } From 4b4e2b2f820bfe46a7e6d333fafd989a107711cf Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 23:24:24 +0200 Subject: [PATCH 14/19] feat(api): add schedule item duration estimates refs #111 --- .../ProgramScheduleItemViewModel.cs | 11 +- ...ogramScheduleItemsWithDurationViewModel.cs | 5 +- ...rogramScheduleItemsWithDurationsHandler.cs | 3 +- .../ScheduleItemDurationEstimator.cs | 7 +- ...mScheduleItemsWithDurationsHandlerTests.cs | 247 ++++++++++++++++++ .../Controllers/ScheduleControllerTests.cs | 10 +- ErsatzTV/wwwroot/openapi/v1.json | 48 +++- 7 files changed, 304 insertions(+), 27 deletions(-) create mode 100644 ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs index cade2456e..4ebf51d6e 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -46,7 +46,7 @@ public abstract record ProgramScheduleItemViewModel( { /// /// A rough estimate, in wall-clock time, of how long a single pass of this schedule item will play, - /// derived from the aggregated runtimes (MediaVersion.Duration) of the referenced content. + /// derived from the aggregated playout runtimes of the referenced content. /// /// Semantics by : /// @@ -57,15 +57,12 @@ public abstract record ProgramScheduleItemViewModel( /// . /// /// Flood — always null: a flood item fills the remaining time and is unbounded. - /// - /// Duration — always null: its runtime is the explicit - /// playoutDuration setting already present on the item, so it is not re-derived here. - /// + /// Duration — the explicit playoutDuration setting on the item. /// /// /// - /// null whenever a bounded estimate cannot be produced — an unbounded mode (Flood/Duration), - /// a referenced collection with no items that have a known non-zero duration, a Multiple mode other + /// null whenever a bounded estimate cannot be produced — an unbounded mode (Flood), + /// a referenced collection with no items that have a known positive duration, a Multiple mode other /// than Count/CollectionSize, or a collection type other than /// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass). /// Callers should treat a null as "unknown", never as zero. diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs index 305cbfb1f..0d0fa4623 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs @@ -10,9 +10,8 @@ namespace ErsatzTV.Application.ProgramSchedules; /// /// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the /// estimable items. null when no item in the schedule could be estimated (for example a -/// schedule made up entirely of Flood/Duration items, or of collection types that are not -/// aggregated). Because unbounded items contribute nothing, this is a lower bound, never an exact -/// schedule length. +/// schedule made up entirely of Flood items, or of collection types that are not aggregated). +/// Because unbounded items contribute nothing, this is a lower bound, never an exact schedule length. /// public record ProgramScheduleItemsWithDurationViewModel( List Items, diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs index 7e2563dd2..72c9a1271 100644 --- a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs @@ -54,7 +54,8 @@ public class GetProgramScheduleItemsWithDurationsHandler( List mediaItems = await mediaCollectionRepository.GetItems(collectionId); List durations = mediaItems - .Bind(mediaItem => mediaItem.GetNonZeroDuration()) + .Map(mediaItem => mediaItem.GetDurationForPlayout()) + .Filter(duration => duration > TimeSpan.Zero) .ToList(); if (durations.Count > 0) diff --git a/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs index 05aeced01..9a6a817a3 100644 --- a/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs +++ b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs @@ -29,6 +29,11 @@ internal static class ScheduleItemDurationEstimator ProgramScheduleItemViewModel item, IReadOnlyDictionary durationsByCollectionId) { + if (item is ProgramScheduleItemDurationViewModel durationItem) + { + return durationItem.PlayoutDuration; + } + // only plain collections are aggregated in this pass if (item.CollectionType is not CollectionType.Collection || item.Collection is null) { @@ -48,7 +53,7 @@ internal static class ScheduleItemDurationEstimator // a fixed count of items, or the whole collection once ProgramScheduleItemMultipleViewModel multiple => EstimateMultiple(multiple, duration), - // Flood (unbounded fill) and Duration (bounded by its own playoutDuration setting) are not derived here + // Flood is an unbounded fill; Duration is handled above from its explicit playoutDuration. _ => null }; } diff --git a/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs new file mode 100644 index 000000000..c1c1d525e --- /dev/null +++ b/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs @@ -0,0 +1,247 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scheduling; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.ProgramSchedules; + +[TestFixture] +public class GetProgramScheduleItemsWithDurationsHandlerTests +{ + private IMediaCollectionRepository _mediaCollectionRepository = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _mediaCollectionRepository = Substitute.For(); + } + + [Test] + public async Task Handle_Should_Estimate_Items_And_Total_From_Collection_Durations() + { + ProgramScheduleItemViewModel one = MakeOneItem(1, 10); + ProgramScheduleItemViewModel multipleCount = MakeMultipleItem(2, 10, MultipleMode.Count, "3"); + ProgramScheduleItemViewModel multipleCollectionSize = MakeMultipleItem(3, 20, MultipleMode.CollectionSize, "0"); + ProgramScheduleItemViewModel duration = MakeDurationItem(4, TimeSpan.FromMinutes(45)); + ProgramScheduleItemViewModel flood = MakeFloodItem(5, 10); + ProgramScheduleItemViewModel remote = MakeOneItem(6, 30); + + _mediator.Send(Arg.Is(q => q.Id == 99), Arg.Any()) + .Returns([one, multipleCount, multipleCollectionSize, duration, flood, remote]); + _mediaCollectionRepository.GetItems(10) + .Returns([MakeMovie(30), MakeMovie(60), MakeMovie(0)]); + _mediaCollectionRepository.GetItems(20) + .Returns([MakeMovie(10), MakeMovie(20)]); + _mediaCollectionRepository.GetItems(30) + .Returns([MakeRemoteStreamWithFallbackDuration(20)]); + + var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository); + + ProgramScheduleItemsWithDurationViewModel result = + await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None); + + result.Items[0].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45)); + result.Items[1].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(135)); + result.Items[2].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(30)); + result.Items[3].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45)); + result.Items[4].DurationEstimate.ShouldBeNull(); + result.Items[5].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(20)); + result.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(275)); + } + + [Test] + public async Task Handle_Should_Return_Null_Total_When_No_Items_Can_Be_Estimated() + { + ProgramScheduleItemViewModel one = MakeOneItem(1, 10); + ProgramScheduleItemViewModel flood = MakeFloodItem(2, 10); + + _mediator.Send(Arg.Is(q => q.Id == 99), Arg.Any()) + .Returns([one, flood]); + _mediaCollectionRepository.GetItems(10) + .Returns([MakeMovie(0)]); + + var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository); + + ProgramScheduleItemsWithDurationViewModel result = + await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None); + + result.Items.ShouldAllBe(item => item.DurationEstimate == null); + result.TotalDurationEstimate.ShouldBeNull(); + } + + private static ProgramScheduleItemOneViewModel MakeOneItem(int id, int collectionId) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(collectionId), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static ProgramScheduleItemMultipleViewModel MakeMultipleItem( + int id, + int collectionId, + MultipleMode multipleMode, + string count) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(collectionId), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + multipleMode, + count, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static ProgramScheduleItemDurationViewModel MakeDurationItem(int id, TimeSpan playoutDuration) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(10), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + playoutDuration, + TailMode.None, + 0, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static ProgramScheduleItemFloodViewModel MakeFloodItem(int id, int collectionId) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(collectionId), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static MediaCollectionViewModel MakeCollection(int id) => + new(CollectionType.Collection, id, $"Collection {id}", false, MediaItemState.Normal); + + private static Movie MakeMovie(int minutes) => + new() + { + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }] + }; + + private static RemoteStream MakeRemoteStreamWithFallbackDuration(int minutes) => + new() + { + Duration = TimeSpan.FromMinutes(minutes), + MediaVersions = [new MediaVersion { Duration = TimeSpan.Zero }] + }; +} diff --git a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs index 151674a4d..91a19e90c 100644 --- a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs @@ -189,14 +189,18 @@ public class ScheduleControllerTests public async Task GetItems_Should_Return_200_With_Items() { List items = [MakeOneItem(11)]; + var response = new ProgramScheduleItemsWithDurationViewModel(items, TimeSpan.FromMinutes(25)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(4, "Daily"))); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(items); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(response); IActionResult result = await _controller.GetItems(4, CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(items); + result.ShouldBeOfType().Value.ShouldBe(response); + await _mediator.Received(1).Send( + Arg.Is(q => q.Id == 4), + Arg.Any()); } [Test] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index eb4ab1c7e..76730ed41 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2580,6 +2580,7 @@ "Schedules" ], "summary": "Get schedule items", + "description": "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are derived from referenced collection/media runtimes and are null when unbounded or unknown.", "parameters": [ { "name": "id", @@ -2597,26 +2598,17 @@ "content": { "text/plain": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" - } + "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" } }, "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" - } + "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" } }, "text/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" - } + "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" } } } @@ -5445,6 +5437,31 @@ } } }, + "ProgramScheduleItemsWithDurationViewModel": { + "required": [ + "items", + "totalDurationEstimate" + ], + "type": "object", + "properties": { + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + }, + "totalDurationEstimate": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, "ProgramScheduleItemViewModel": { "type": "object", "properties": { @@ -5604,6 +5621,13 @@ } ] }, + "durationEstimate": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, "name": { "type": [ "null", From 14d06a1e497f654aa51485b3cc3a38f4a4d428eb Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 23:36:27 +0200 Subject: [PATCH 15/19] docs: note expression-based counts yield null duration estimate Adversarial review finding on #111: MultipleMode.Count supports expressions the estimator cannot evaluate; document that they produce a null (unknown) estimate. Co-Authored-By: Claude Fable 5 --- .../ProgramSchedules/ProgramScheduleItemViewModel.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs index 4ebf51d6e..99ce25574 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -54,7 +54,8 @@ public abstract record ProgramScheduleItemViewModel( /// /// Multiple — the average item runtime multiplied by the configured count /// (), or the whole collection runtime for - /// . + /// . An expression-based (non-integer) + /// count cannot be evaluated here and yields null. /// /// Flood — always null: a flood item fills the remaining time and is unbounded. /// Duration — the explicit playoutDuration setting on the item. From 9ecefd75f331741626e17036b44b62a5b71ea9e1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:37 +0200 Subject: [PATCH 16/19] feat(api): add JSON channel guide endpoint (#102) Add GET /api/guide returning a per-channel EPG grid (ChannelGuideResponseModel). start defaults to now, end defaults to now + XmltvDaysToBuild. Extract the guide-group/filler-merge projection (ChannelGuideProjector) and programme-metadata resolution (ChannelGuideMetadata) plus the metadata eager-load (IncludeGuideMetadata) out of RefreshChannelDataHandler so the XMLTV cache builder and the JSON guide share one source of truth and cannot drift. XMLTV output is unchanged (ChannelGuideGoldenTests pass without regeneration). The JSON handler mirrors XMLTV semantics: ShowInEpg-only channels, decimal channel ordering, ExternalJson playouts skipped, mirror playout offset applied to copies (never mutating the loaded entities). SubTitle/Category are nullable. refs #102 Co-Authored-By: Claude Fable 5 --- .../Channels/ChannelGuideMetadata.cs | 71 ++++ .../Channels/ChannelGuideProjector.cs | 164 ++++++++ .../Commands/RefreshChannelDataHandler.cs | 353 ++---------------- .../Channels/Queries/GetChannelGuideData.cs | 10 + .../Queries/GetChannelGuideDataHandler.cs | 145 +++++++ .../Api/Channels/ChannelGuideResponseModel.cs | 25 ++ .../PlayoutGuideQueryableExtensions.cs | 98 +++++ ErsatzTV/Controllers/Api/ChannelController.cs | 14 + 8 files changed, 565 insertions(+), 315 deletions(-) create mode 100644 ErsatzTV.Application/Channels/ChannelGuideMetadata.cs create mode 100644 ErsatzTV.Application/Channels/ChannelGuideProjector.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs create mode 100644 ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs create mode 100644 ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs create mode 100644 ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs diff --git a/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs b/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs new file mode 100644 index 000000000..6c4e12e4c --- /dev/null +++ b/ErsatzTV.Application/Channels/ChannelGuideMetadata.cs @@ -0,0 +1,71 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Channels; + +/// +/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder +/// () and the JSON guide query +/// () resolve the display title/subtitle/category from a +/// here so the two representations stay consistent. +/// +public static class ChannelGuideMetadata +{ + public static string GetTitle(PlayoutItem playoutItem) + { + if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) + { + return playoutItem.CustomTitle; + } + + return playoutItem.MediaItem switch + { + Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty) + .IfNone("[unknown movie]"), + Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty) + .IfNone("[unknown show]"), + MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty) + .IfNone("[unknown artist]"), + OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) + .IfNone("[unknown video]"), + RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) + .IfNone("[unknown remote stream]"), + _ => "[unknown]" + }; + } + + public static string GetSubtitle(PlayoutItem playoutItem) + { + if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) + { + return string.Empty; + } + + return playoutItem.MediaItem switch + { + Episode e => e.EpisodeMetadata.HeadOrNone().Match( + em => em.Title ?? string.Empty, + () => string.Empty), + MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match( + mvm => mvm.Title ?? string.Empty, + () => string.Empty), + Song s => s.SongMetadata.HeadOrNone().Match( + mvm => mvm.Title ?? string.Empty, + () => string.Empty), + _ => string.Empty + }; + } + + /// + /// The primary guide category, mirroring the fixed <category> the XMLTV templates + /// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null. + /// + public static string GetCategory(PlayoutItem playoutItem) => + playoutItem.MediaItem switch + { + Movie => "Movie", + Episode => "Series", + MusicVideo => "Music", + Song => "Music", + _ => null + }; +} diff --git a/ErsatzTV.Application/Channels/ChannelGuideProjector.cs b/ErsatzTV.Application/Channels/ChannelGuideProjector.cs new file mode 100644 index 000000000..4ece0abb9 --- /dev/null +++ b/ErsatzTV.Application/Channels/ChannelGuideProjector.cs @@ -0,0 +1,164 @@ +using ErsatzTV.Application.Configuration; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Application.Channels; + +/// +/// A single guide programme resolved from one or more s: the +/// whose metadata is shown, plus the coalesced / +/// window and whether the originating item carried a custom title. +/// +public readonly record struct ChannelGuideEntry( + PlayoutItem DisplayItem, + DateTimeOffset Start, + DateTimeOffset Stop, + bool HasCustomTitle); + +/// +/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a +/// channel's sorted s into guide programmes; both the XMLTV cache builder +/// () and the JSON guide query +/// () consume it so the two representations cannot drift. +/// The XMLTV path formats / +/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's +/// ) directly and lets the UI decide how to render filler. +/// +public static class ChannelGuideProjector +{ + public static IEnumerable Project( + PlayoutScheduleKind scheduleKind, + IReadOnlyList sorted, + XmltvTimeZone timeZone, + XmltvBlockBehavior blockBehavior) => + scheduleKind switch + { + PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior), + _ => ProjectFlood(sorted, timeZone) + }; + + // Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce + // each guide group (following filler) into a single programme using the display item's GuideFinish + // override when present. + private static IEnumerable ProjectFlood( + IReadOnlyList sorted, + XmltvTimeZone timeZone) + { + // skip all filler that isn't pre-roll + var i = 0; + while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None && + sorted[i].FillerKind != FillerKind.PreRoll) + { + i++; + } + + while (i < sorted.Count) + { + PlayoutItem startItem = sorted[i]; + int j = i; + while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count) + { + j++; + } + + PlayoutItem displayItem = sorted[j]; + bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle); + + int finishIndex = j; + while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup + || sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode + or FillerKind.PostRoll or FillerKind.Tail + or FillerKind.Fallback or FillerKind.DecoDefault)) + { + finishIndex++; + } + + PlayoutItem finishItem = sorted[finishIndex]; + i = finishIndex; + + DateTimeOffset startTime = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero), + _ => startItem.StartOffset + }; + + DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch + { + (XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero), + (XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero), + (_, true) => displayItem.GuideFinishOffset!.Value, + (_, false) => finishItem.FinishOffset + }; + + yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle); + + i++; + } + } + + // Block: group by guide window, drop filler entirely, then either use the items' actual times or + // split the group window evenly across the non-filler items. + private static IEnumerable ProjectBlock( + IReadOnlyList sorted, + XmltvTimeZone timeZone, + XmltvBlockBehavior blockBehavior) + { + var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup }); + foreach (var group in groups) + { + var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList(); + if (itemsToInclude.Count == 0) + { + continue; + } + + switch (blockBehavior) + { + case XmltvBlockBehavior.UseActualTimes: + foreach (PlayoutItem item in itemsToInclude) + { + DateTimeOffset actualStart = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero), + _ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime() + }; + + DateTimeOffset actualFinish = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero), + _ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime() + }; + + yield return new ChannelGuideEntry(item, actualStart, actualFinish, false); + } + + break; + case XmltvBlockBehavior.SplitTimeEvenly: + default: + DateTime groupStart = group.Key.GuideStart!.Value; + DateTime groupFinish = group.Key.GuideFinish!.Value; + TimeSpan groupDuration = groupFinish - groupStart; + + TimeSpan perItem = groupDuration / itemsToInclude.Count; + + DateTimeOffset currentStart = timeZone switch + { + XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero), + _ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime() + }; + + DateTimeOffset currentFinish = currentStart + perItem; + + foreach (PlayoutItem item in itemsToInclude) + { + yield return new ChannelGuideEntry(item, currentStart, currentFinish, false); + + currentStart = currentFinish; + currentFinish += perItem; + } + + break; + } + } + } +} diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index 5d0207327..9d50f1201 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler List playouts = await dbContext.Playouts .AsNoTracking() .Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber)) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).EpisodeMetadata) - .ThenInclude(em => em.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).EpisodeMetadata) - .ThenInclude(em => em.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Studios) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Directors) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Artists) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).Artist) - .ThenInclude(a => a.ArtistMetadata) - .ThenInclude(am => am.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(sm => sm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(sm => sm.Studios) + .IncludeGuideMetadata() .AsSplitQuery() .ToListAsync(cancellationToken); @@ -244,8 +162,9 @@ public class RefreshChannelDataHandler : IRequestHandler item.Finish += playoutOffset; } - await WritePlayoutXml( + await WriteScheduleXml( request, + playout.ScheduleKind, floodSorted, templateContext, movieTemplate, @@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler item.Finish += playoutOffset; } - await WriteBlockPlayoutXml( + await WriteScheduleXml( request, + playout.ScheduleKind, blockSorted, templateContext, movieTemplate, @@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler item.Finish += playoutOffset; } - await WritePlayoutXml( + await WriteScheduleXml( request, + playout.ScheduleKind, externalJsonSorted, templateContext, movieTemplate, @@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler } } - private async Task WritePlayoutXml( - RefreshChannelData request, - List sorted, - XmlTemplateContext templateContext, - Template movieTemplate, - Template episodeTemplate, - Template musicVideoTemplate, - Template songTemplate, - Template otherVideoTemplate, - Template remoteStreamTemplate, - XmlMinifier minifier, - XmlWriter xml, - CancellationToken cancellationToken) - { - XmltvTimeZone xmltvTimeZone = await _configElementRepository - .GetValue(ConfigElementKey.XmltvTimeZone, cancellationToken) - .IfNoneAsync(XmltvTimeZone.Local); - - // skip all filler that isn't pre-roll - var i = 0; - while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None && - sorted[i].FillerKind != FillerKind.PreRoll) - { - i++; - } - - while (i < sorted.Count) - { - PlayoutItem startItem = sorted[i]; - int j = i; - while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count) - { - j++; - } - - PlayoutItem displayItem = sorted[j]; - bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle); - - int finishIndex = j; - while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup - || sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode - or FillerKind.PostRoll or FillerKind.Tail - or FillerKind.Fallback or FillerKind.DecoDefault)) - { - finishIndex++; - } - - PlayoutItem finishItem = sorted[finishIndex]; - i = finishIndex; - - DateTimeOffset startTime = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero), - _ => startItem.StartOffset - }; - - DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch - { - (XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero), - (XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero), - (_, true) => displayItem.GuideFinishOffset!.Value, - (_, false) => finishItem.FinishOffset - }; - - string start = startTime - .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - string stop = stopTime - .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - await WriteItemToXml( - request, - displayItem, - start, - stop, - hasCustomTitle, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - remoteStreamTemplate, - minifier, - xml); - - i++; - } - } - - private async Task WriteBlockPlayoutXml( + private async Task WriteScheduleXml( RefreshChannelData request, + PlayoutScheduleKind scheduleKind, List sorted, XmlTemplateContext templateContext, Template movieTemplate, @@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler .GetValue(ConfigElementKey.XmltvBlockBehavior, cancellationToken) .IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly); - var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup }); - foreach (var group in groups) + // guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift + foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project( + scheduleKind, + sorted, + xmltvTimeZone, + xmltvBlockBehavior)) { - var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList(); - if (itemsToInclude.Count == 0) - { - continue; - } + string start = entry.Start + .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) + .Replace(":", string.Empty); - switch (xmltvBlockBehavior) - { - case XmltvBlockBehavior.UseActualTimes: - foreach (PlayoutItem item in itemsToInclude) - { - DateTimeOffset actualStart = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero), - _ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime() - }; + string stop = entry.Stop + .ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) + .Replace(":", string.Empty); - DateTimeOffset actualFinish = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero), - _ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime() - }; - - string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - await WriteItemToXml( - request, - item, - start, - stop, - false, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - remoteStreamTemplate, - minifier, - xml); - } - break; - case XmltvBlockBehavior.SplitTimeEvenly: - default: - DateTime groupStart = group.Key.GuideStart!.Value; - DateTime groupFinish = group.Key.GuideFinish!.Value; - TimeSpan groupDuration = groupFinish - groupStart; - - TimeSpan perItem = groupDuration / itemsToInclude.Count; - - DateTimeOffset currentStart = xmltvTimeZone switch - { - XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero), - _ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime() - }; - - DateTimeOffset currentFinish = currentStart + perItem; - - foreach (PlayoutItem item in itemsToInclude) - { - string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture) - .Replace(":", string.Empty); - - await WriteItemToXml( - request, - item, - start, - stop, - false, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - remoteStreamTemplate, - minifier, - xml); - - currentStart = currentFinish; - currentFinish += perItem; - } - break; - } + await WriteItemToXml( + request, + entry.DisplayItem, + start, + stop, + entry.HasCustomTitle, + templateContext, + movieTemplate, + episodeTemplate, + musicVideoTemplate, + songTemplate, + otherVideoTemplate, + remoteStreamTemplate, + minifier, + xml); } } @@ -549,8 +317,8 @@ public class RefreshChannelDataHandler : IRequestHandler XmlMinifier minifier, XmlWriter xml) { - string title = GetTitle(displayItem); - string subtitle = GetSubtitle(displayItem); + string title = ChannelGuideMetadata.GetTitle(displayItem); + string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem); Option maybeTemplateOutput = displayItem.MediaItem switch { @@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler return artworkPath; } - private static string GetTitle(PlayoutItem playoutItem) - { - if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) - { - return playoutItem.CustomTitle; - } - - return playoutItem.MediaItem switch - { - Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty) - .IfNone("[unknown movie]"), - Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty) - .IfNone("[unknown show]"), - MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty) - .IfNone("[unknown artist]"), - OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) - .IfNone("[unknown video]"), - RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) - .IfNone("[unknown remote stream]"), - _ => "[unknown]" - }; - } - - private static string GetSubtitle(PlayoutItem playoutItem) - { - if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) - { - return string.Empty; - } - - return playoutItem.MediaItem switch - { - Episode e => e.EpisodeMetadata.HeadOrNone().Match( - em => em.Title ?? string.Empty, - () => string.Empty), - MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match( - mvm => mvm.Title ?? string.Empty, - () => string.Empty), - Song s => s.SongMetadata.HeadOrNone().Match( - mvm => mvm.Title ?? string.Empty, - () => string.Empty), - _ => string.Empty - }; - } - private static string GetPrioritizedArtworkPath(Metadata metadata) { Option maybeArtwork = Optional(metadata.Artwork).Flatten() diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs new file mode 100644 index 000000000..19dd89d38 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideData.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Api.Channels; + +namespace ErsatzTV.Application.Channels; + +/// +/// JSON channel-guide query for the EPG grid. defaults to now and +/// defaults to now + the configured XmltvDaysToBuild window. +/// +public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End) + : IRequest; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs new file mode 100644 index 000000000..b4294b068 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideDataHandler.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using ErsatzTV.Application.Configuration; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Channels; + +/// +/// Builds the JSON channel guide directly from items, using the shared +/// guide-group/filler-merge logic (the same logic the XMLTV +/// cache builder uses) so the two representations cannot drift. Only channels with +/// are included, mirroring GetChannelGuideHandler. +/// Unlike XMLTV, filler programmes are returned (with their ) +/// so the UI can decide how to render them. +/// +public class GetChannelGuideDataHandler( + IDbContextFactory dbContextFactory, + IConfigElementRepository configElementRepository) + : IRequestHandler +{ + public async Task Handle( + GetChannelGuideData request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + int daysToBuild = await configElementRepository + .GetValue(ConfigElementKey.XmltvDaysToBuild, cancellationToken) + .IfNoneAsync(2); + + XmltvTimeZone xmltvTimeZone = await configElementRepository + .GetValue(ConfigElementKey.XmltvTimeZone, cancellationToken) + .IfNoneAsync(XmltvTimeZone.Local); + + XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository + .GetValue(ConfigElementKey.XmltvBlockBehavior, cancellationToken) + .IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly); + + DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow; + DateTimeOffset end = request.End ?? start.AddDays(daysToBuild); + + // Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip). + List channels = await dbContext.Channels + .AsNoTracking() + .Where(c => c.ShowInEpg) + .Include(c => c.MirrorSourceChannel) + .ToListAsync(cancellationToken); + + // Order channels by their decimal channel number so "2" precedes "10", matching + // ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number). + channels = channels + .OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)) + .ToList(); + + var responseChannels = new List(); + + foreach (Channel channel in channels) + { + bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror + && channel.MirrorSourceChannel is not null; + + string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number; + TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero; + + List playouts = await dbContext.Playouts + .AsNoTracking() + .Filter(p => p.Channel.Number == sourceChannelNumber) + .IncludeGuideMetadata() + .AsSplitQuery() + .ToListAsync(cancellationToken); + + var programmes = new List(); + + foreach (Playout playout in playouts) + { + // ExternalJson playouts materialize items from a file rather than Playout.Items; they are + // out of scope for the JSON guide (see issue #102 notes). + if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson) + { + continue; + } + + // Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the + // mirror playout offset without mutating the loaded (shared, AsNoTracking) entities. + List sorted = playout.Items + .OrderBy(pi => pi.Start) + .Filter(pi => pi.StartOffset <= end) + .Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset)) + .ToList(); + + foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project( + playout.ScheduleKind, + sorted, + xmltvTimeZone, + xmltvBlockBehavior)) + { + // drop programmes that finish before the requested window starts + if (entry.Stop <= start) + { + continue; + } + + string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem); + + programmes.Add( + new ChannelGuideProgrammeResponseModel( + entry.Start, + entry.Stop, + ChannelGuideMetadata.GetTitle(entry.DisplayItem), + string.IsNullOrWhiteSpace(subtitle) ? null : subtitle, + ChannelGuideMetadata.GetCategory(entry.DisplayItem), + entry.DisplayItem.FillerKind)); + } + } + + responseChannels.Add( + new ChannelGuideChannelResponseModel( + channel.Number, + channel.Name, + programmes.OrderBy(p => p.Start).ToList())); + } + + return new ChannelGuideResponseModel(start, end, responseChannels); + } + + // Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded + // entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection. + // Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window). + private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) => + new() + { + MediaItem = item.MediaItem, + Start = item.Start + offset, + Finish = item.Finish + offset, + GuideStart = item.GuideStart, + GuideFinish = item.GuideFinish, + GuideGroup = item.GuideGroup, + FillerKind = item.FillerKind, + CustomTitle = item.CustomTitle + }; +} diff --git a/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs new file mode 100644 index 000000000..cc968cff0 --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/ChannelGuideResponseModel.cs @@ -0,0 +1,25 @@ +#nullable enable +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Core.Api.Channels; + +/// A single guide programme for the JSON EPG grid. +public record ChannelGuideProgrammeResponseModel( + DateTimeOffset Start, + DateTimeOffset Stop, + string Title, + string? SubTitle, + string? Category, + FillerKind FillerKind); + +/// One channel's guide programmes for the requested window. +public record ChannelGuideChannelResponseModel( + string Number, + string Name, + List Programmes); + +/// The JSON channel-guide response: the resolved window plus per-channel programme arrays. +public record ChannelGuideResponseModel( + DateTimeOffset Start, + DateTimeOffset End, + List Channels); diff --git a/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs b/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs new file mode 100644 index 000000000..68c14080d --- /dev/null +++ b/ErsatzTV.Infrastructure/Extensions/PlayoutGuideQueryableExtensions.cs @@ -0,0 +1,98 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Extensions; + +public static class PlayoutGuideQueryableExtensions +{ + /// + /// Eager-loads the full playout-item metadata graph needed to render guide programme + /// titles/subtitles/categories/artwork. Shared by the XMLTV cache builder and the JSON guide + /// query so both surfaces see identical data. Callers should apply AsSplitQuery(). + /// + public static IQueryable IncludeGuideMetadata(this IQueryable playouts) => + playouts + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).EpisodeMetadata) + .ThenInclude(em => em.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).EpisodeMetadata) + .ThenInclude(em => em.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Studios) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Directors) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Artists) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).Artist) + .ThenInclude(a => a.ArtistMetadata) + .ThenInclude(am => am.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(sm => sm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(sm => sm.Studios); +} diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 76577ca10..272764e70 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -22,6 +22,20 @@ public class ChannelController(ChannelWriter workerCh [EndpointGroupName("general")] public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi()); + [HttpGet("/api/guide")] + [Tags("Channels")] + [EndpointSummary("Get the JSON channel guide (EPG)")] + [EndpointDescription( + "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now " + + "and end defaults to now plus the configured XmltvDaysToBuild window.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)] + public async Task GetGuide( + [FromQuery] DateTimeOffset? start, + [FromQuery] DateTimeOffset? end, + CancellationToken cancellationToken) => + await mediator.Send(new GetChannelGuideData(start, end), cancellationToken); + [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] [EndpointSummary("Get a channel by id")] From 12aa57ccc90b3318485cd26a07da99b84a23934e Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 4 Jul 2026 00:55:07 +0200 Subject: [PATCH 17/19] test(api): cover JSON channel guide handler and endpoint (#102) Handler tests (in-memory SQLite harness): ShowInEpg filtering, decimal channel ordering, time-window filtering, custom-title passthrough, leading pre-roll filler merge, block split-time-evenly, default start/end from config, empty guide. Controller tests: query mapping and null-bounds passthrough for GetGuide. refs #102 Co-Authored-By: Claude Fable 5 --- .../GetChannelGuideDataHandlerTests.cs | 281 ++++++++++++++++++ .../Controllers/ChannelControllerTests.cs | 30 ++ 2 files changed, 311 insertions(+) create mode 100644 ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs new file mode 100644 index 000000000..cf7cca668 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs @@ -0,0 +1,281 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Configuration; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using DomainChannel = ErsatzTV.Core.Domain.Channel; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class GetChannelGuideDataHandlerTests +{ + private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc); + + private InMemoryTvContext _db = null!; + private IConfigElementRepository _config = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _config = Substitute.For(); + + // ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we + // match by the generic value type (GetValue) with Arg.Any key. Pin the time zone to UTC so + // projected times are deterministic regardless of the machine/CI time zone, and split block time + // evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test. + _config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some(XmltvTimeZone.Utc)); + _config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some(XmltvBlockBehavior.SplitTimeEvenly)); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config); + + [Test] + public async Task Handle_Should_Only_Include_Visible_Channels() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel visible = NewChannel("2", "Visible", showInEpg: true); + DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false); + visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))]; + hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))]; + context.Channels.AddRange(visible, hidden); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + result.Channels.Select(c => c.Number).ShouldBe(["2"]); + } + + [Test] + public async Task Handle_Should_Order_Channels_By_Decimal_Number() + { + await using (TvContext context = _db.CreateContext()) + { + context.Channels.Add(NewChannel("10", "Ten", showInEpg: true)); + context.Channels.Add(NewChannel("2", "Two", showInEpg: true)); + context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true)); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + // decimal order: 2 < 5.1 < 10 (string order would put "10" first) + result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]); + } + + [Test] + public async Task Handle_Should_Filter_Programmes_To_Requested_Window() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + channel.Playouts = + [ + MakeFloodPlayout( + channel, + (BaseTime, BaseTime.AddHours(1), 1, "Before Window"), + (BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window")) + ]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + // window starts after the first programme has finished + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)), + CancellationToken.None); + + List programmes = result.Channels.Single().Programmes; + programmes.Select(p => p.Title).ShouldBe(["In Window"]); + } + + [Test] + public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored"); + item.CustomTitle = "Custom Title"; + channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single(); + programme.Title.ShouldBe("Custom Title"); + programme.SubTitle.ShouldBeNull(); + programme.Category.ShouldBe("Movie"); + } + + [Test] + public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper"); + preRoll.FillerKind = FillerKind.PreRoll; + PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature"); + channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single(); + // filler is merged: the programme starts at the pre-roll start but displays the feature metadata + programme.Title.ShouldBe("Feature"); + programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero)); + programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero)); + programme.FillerKind.ShouldBe(FillerKind.None); + } + + [Test] + public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items() + { + await using (TvContext context = _db.CreateContext()) + { + DomainChannel channel = NewChannel("2", "Two", showInEpg: true); + PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One"); + PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two"); + foreach (PlayoutItem item in new[] { one, two }) + { + item.GuideStart = BaseTime; + item.GuideFinish = BaseTime.AddHours(1); + } + + channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])]; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + List programmes = result.Channels.Single().Programmes; + programmes.Count.ShouldBe(2); + // 1-hour guide window split evenly across the 2 content items -> 30 minutes each + programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero)); + programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero)); + programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero)); + programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero)); + } + + [Test] + public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild() + { + _config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some(3)); + + DateTimeOffset before = DateTimeOffset.UtcNow; + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(null, null), + CancellationToken.None); + DateTimeOffset after = DateTimeOffset.UtcNow; + + result.Start.ShouldBeInRange(before, after); + (result.End - result.Start).ShouldBe(TimeSpan.FromDays(3)); + } + + [Test] + public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout() + { + await using (TvContext context = _db.CreateContext()) + { + context.Channels.Add(NewChannel("2", "Two", showInEpg: true)); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + result.Channels.Single().Programmes.ShouldBeEmpty(); + } + + // --- seeding helpers --- + + private static Playout MakeFloodPlayout( + DomainChannel channel, + params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) => + MakePlayout( + channel, + PlayoutScheduleKind.Classic, + items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList()); + + private static Playout MakePlayout( + DomainChannel channel, + PlayoutScheduleKind scheduleKind, + List items) => + new() + { + Channel = channel, + ScheduleKind = scheduleKind, + ScheduleFile = string.Empty, + Items = items + }; + + private static PlayoutItem MakeItem( + DateTime start, + DateTime finish, + int guideGroup, + string movieTitle) => + new() + { + Start = start, + Finish = finish, + GuideGroup = guideGroup, + FillerKind = FillerKind.None, + MediaItem = new Movie + { + MovieMetadata = [new MovieMetadata { Title = movieTitle }], + MediaVersions = [] + } + }; + + private static DomainChannel NewChannel(string number, string name, bool showInEpg) => + new(Guid.NewGuid()) + { + Number = number, + Name = name, + Group = "ErsatzTV", + Categories = string.Empty, + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + ShowInEpg = showInEpg, + Playouts = [] + }; +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 1a03ca2e0..65def054f 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; +using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; @@ -126,6 +127,35 @@ public class ChannelControllerTests result.ShouldBeOfType(); } + [Test] + public async Task GetGuide_Should_Map_Query_And_Return_Model() + { + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var end = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero); + var model = new ChannelGuideResponseModel(start, end, []); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(model); + + ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None); + + result.ShouldBe(model); + await _mediator.Received(1).Send( + Arg.Is(q => q.Start == start && q.End == end), + Arg.Any()); + } + + [Test] + public async Task GetGuide_Should_Pass_Null_Bounds_Through() + { + var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(model); + + await _controller.GetGuide(null, null, CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => q.Start == null && q.End == null), + Arg.Any()); + } + [Test] public async Task GetById_Should_Return_200_For_Some() { From ef024c0a054b865c96f470186d42fe35e9f6667b Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 4 Jul 2026 00:55:07 +0200 Subject: [PATCH 18/19] chore(openapi): regenerate v1 for JSON channel guide endpoint (#102) refs #102 Co-Authored-By: Claude Fable 5 --- ErsatzTV/wwwroot/openapi/v1.json | 134 +++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 76730ed41..ffd9ea5c5 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -215,6 +215,55 @@ } } }, + "/api/guide": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get the JSON channel guide (EPG)", + "description": "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now and end defaults to now plus the configured XmltvDaysToBuild window.", + "parameters": [ + { + "name": "start", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "end", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ChannelGuideResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelGuideResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ChannelGuideResponseModel" + } + } + } + } + } + } + }, "/api/channels/{id}": { "get": { "tags": [ @@ -3587,6 +3636,91 @@ } } }, + "ChannelGuideChannelResponseModel": { + "required": [ + "number", + "name", + "programmes" + ], + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "name": { + "type": "string" + }, + "programmes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelGuideProgrammeResponseModel" + } + } + } + }, + "ChannelGuideProgrammeResponseModel": { + "required": [ + "start", + "stop", + "title", + "subTitle", + "category", + "fillerKind" + ], + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "stop": { + "type": "string", + "format": "date-time" + }, + "title": { + "type": "string" + }, + "subTitle": { + "type": [ + "null", + "string" + ] + }, + "category": { + "type": [ + "null", + "string" + ] + }, + "fillerKind": { + "$ref": "#/components/schemas/FillerKind" + } + } + }, + "ChannelGuideResponseModel": { + "required": [ + "start", + "end", + "channels" + ], + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelGuideChannelResponseModel" + } + } + } + }, "ChannelIdleBehavior": { "enum": [ "StopOnDisconnect", From 0585f4a7f8f1597b5024a09e3660fa6e77dd339b Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 4 Jul 2026 01:19:58 +0200 Subject: [PATCH 19/19] test(api): pin mirror-offset guide behavior; clarify guide end-default wording (#102) - Add GetChannelGuideDataHandlerTests coverage for the mirror-channel path: asserts programme Start/Stop shift by PlayoutOffset and that the source channel's seeded PlayoutItem entities are not mutated, pinning the WithPlayoutOffset copy fix. - ChannelController.GetGuide Swagger description said end defaults to "now plus XmltvDaysToBuild"; it actually defaults to start plus that window (matters when start is supplied). Reworded and regenerated v1.json. --- .../GetChannelGuideDataHandlerTests.cs | 35 +++++++++++++++++++ ErsatzTV/Controllers/Api/ChannelController.cs | 4 +-- ErsatzTV/wwwroot/openapi/v1.json | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs index cf7cca668..806c7a64d 100644 --- a/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/GetChannelGuideDataHandlerTests.cs @@ -205,6 +205,41 @@ public class GetChannelGuideDataHandlerTests (result.End - result.Start).ShouldBe(TimeSpan.FromDays(3)); } + [Test] + public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items() + { + PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show"); + var playoutOffset = TimeSpan.FromHours(3); + + await using (TvContext context = _db.CreateContext()) + { + DomainChannel source = NewChannel("1", "Source", showInEpg: false); + source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])]; + + DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true); + mirror.PlayoutSource = ChannelPlayoutSource.Mirror; + mirror.MirrorSourceChannel = source; + mirror.PlayoutOffset = playoutOffset; + + context.Channels.AddRange(source, mirror); + await context.SaveChangesAsync(); + } + + ChannelGuideResponseModel result = await MakeHandler().Handle( + new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)), + CancellationToken.None); + + ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single(); + programme.Title.ShouldBe("Live Show"); + programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero)); + programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero)); + + // the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's + // own (shared, AsNoTracking) PlayoutItem entities in place. + sourceItem.Start.ShouldBe(BaseTime); + sourceItem.Finish.ShouldBe(BaseTime.AddHours(1)); + } + [Test] public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout() { diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 272764e70..c5d71d8ad 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -26,8 +26,8 @@ public class ChannelController(ChannelWriter workerCh [Tags("Channels")] [EndpointSummary("Get the JSON channel guide (EPG)")] [EndpointDescription( - "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now " + - "and end defaults to now plus the configured XmltvDaysToBuild window.")] + "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, " + + "and end defaults to start plus the configured XmltvDaysToBuild window.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)] public async Task GetGuide( diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index ffd9ea5c5..72654b915 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -221,7 +221,7 @@ "Channels" ], "summary": "Get the JSON channel guide (EPG)", - "description": "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now and end defaults to now plus the configured XmltvDaysToBuild window.", + "description": "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, and end defaults to start plus the configured XmltvDaysToBuild window.", "parameters": [ { "name": "start",