From 640ffabba8bec059697133249769690c749e9d51 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 29 Jun 2026 03:00:58 +0000 Subject: [PATCH 0001/1118] chore(deps): update dependency cliwrap to 3.10.2 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1582409d9..46a493faa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + -- 2.47.3 From b5529057997cbe6a20a8709e702759defc6ba799 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 29 Jun 2026 08:13:14 +0200 Subject: [PATCH 0002/1118] feat(api): add schedule REST endpoints Refs #36 --- .../Commands/AddProgramScheduleItemHandler.cs | 36 +- .../Commands/DeleteProgramScheduleHandler.cs | 12 +- .../Commands/DeleteProgramScheduleItem.cs | 6 + .../DeleteProgramScheduleItemHandler.cs | 46 + .../ProgramScheduleItemCommandBase.cs | 10 +- .../ReplaceProgramScheduleItemsHandler.cs | 32 +- .../Commands/UpdateProgramScheduleHandler.cs | 21 +- .../ProgramScheduleHandlerTests.cs | 217 ++ .../ApiErrorResponseMetadataTests.cs | 14 + .../OpenApiErrorResponseContractTests.cs | 30 + .../Controllers/ScheduleControllerTests.cs | 394 ++++ .../ScheduleItemTptIntegrationTests.cs | 188 ++ .../Api/Requests/CreateScheduleRequest.cs | 22 + .../Requests/ReplaceScheduleItemsRequest.cs | 11 + .../Api/Requests/ScheduleItemRequest.cs | 127 ++ .../Api/Requests/UpdateScheduleRequest.cs | 23 + .../Controllers/Api/ScheduleController.cs | 167 ++ ErsatzTV/Startup.cs | 42 +- ErsatzTV/wwwroot/openapi/v1.json | 1922 ++++++++++++++++- 19 files changed, 3270 insertions(+), 50 deletions(-) create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItem.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItemHandler.cs create mode 100644 ErsatzTV.Tests/Application/ProgramSchedules/ProgramScheduleHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs create mode 100644 ErsatzTV.Tests/Integration/ScheduleItemTptIntegrationTests.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ReplaceScheduleItemsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs create mode 100644 ErsatzTV/Controllers/Api/ScheduleController.cs diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs index 6228d4ecb..ab4da04d9 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs @@ -2,6 +2,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -28,8 +29,16 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken)); + Option maybeProgramSchedule = + await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken); + return await maybeProgramSchedule.Match( + Some: async programSchedule => + { + Validation validation = await Validate(dbContext, request, programSchedule); + return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken)); + }, + None: () => Task.FromResult>( + new NotFoundError("[ProgramScheduleId] does not exist."))); } private async Task PersistItem( @@ -54,10 +63,25 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase, return ProjectToViewModel(item); } - private static Task> Validate( + private static async Task> Validate( TvContext dbContext, AddProgramScheduleItem request, - CancellationToken cancellationToken) => - ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken) - .BindT(programSchedule => PlayoutModeMustBeValid(request, programSchedule)); + ProgramSchedule programSchedule) + { + Validation validation = + PlayoutModeMustBeValid(request, programSchedule) + .Bind(programSchedule => CollectionTypeMustBeValid(request, programSchedule)); + + return await validation.ToEither().Match( + Left: error => Task.FromResult>( + Fail(error)), + Right: async validProgramSchedule => + { + Either fillerResult = await FillerConfigurationMustBeValid( + dbContext, + request, + validProgramSchedule); + return fillerResult.ToValidation(); + }); + } } diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs index 0d7756475..e22a999e6 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; @@ -18,11 +19,14 @@ public class DeleteProgramScheduleHandler : IRequestHandler validation = await ProgramScheduleMustExist( + Option maybeProgramSchedule = await ProgramScheduleMustExist( dbContext, request, cancellationToken); - return await validation.Apply(ps => DoDeletion(dbContext, ps)); + return await maybeProgramSchedule.Match( + Some: programSchedule => DoDeletion(dbContext, programSchedule).Map(Right), + None: () => Task.FromResult>( + new NotFoundError($"ProgramSchedule {request.ProgramScheduleId} does not exist."))); } private static Task DoDeletion(TvContext dbContext, ProgramSchedule programSchedule) @@ -31,11 +35,11 @@ public class DeleteProgramScheduleHandler : IRequestHandler> ProgramScheduleMustExist( + private static Task> ProgramScheduleMustExist( TvContext dbContext, DeleteProgramSchedule request, CancellationToken cancellationToken) => dbContext.ProgramSchedules .SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken) - .Map(o => o.ToValidation($"ProgramSchedule {request.ProgramScheduleId} does not exist.")); + .Map(identity); } diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItem.cs b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItem.cs new file mode 100644 index 000000000..0e99d1a9b --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItem.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.ProgramSchedules; + +public record DeleteProgramScheduleItem(int ProgramScheduleId, int ProgramScheduleItemId) + : IRequest>; diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItemHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItemHandler.cs new file mode 100644 index 000000000..f54ccdd5f --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/DeleteProgramScheduleItemHandler.cs @@ -0,0 +1,46 @@ +using System.Threading.Channels; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.ProgramSchedules; + +public class DeleteProgramScheduleItemHandler( + IDbContextFactory dbContextFactory, + ChannelWriter channel) : IRequestHandler> +{ + public async Task> Handle( + DeleteProgramScheduleItem request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + ProgramScheduleItem item = await dbContext.ProgramScheduleItems + .Include(i => i.ProgramSchedule) + .ThenInclude(ps => ps.Playouts) + .SingleOrDefaultAsync( + i => i.Id == request.ProgramScheduleItemId && i.ProgramScheduleId == request.ProgramScheduleId, + cancellationToken); + + if (item is null) + { + return new NotFoundError( + $"ProgramScheduleItem {request.ProgramScheduleItemId} does not exist on schedule {request.ProgramScheduleId}."); + } + + List playouts = item.ProgramSchedule.Playouts; + dbContext.ProgramScheduleItems.Remove(item); + await dbContext.SaveChangesAsync(cancellationToken); + + foreach (Playout playout in playouts) + { + await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken); + } + + return Unit.Default; + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs index 9b682c8bc..3bec3ff06 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs @@ -1,6 +1,7 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Errors; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; @@ -9,7 +10,7 @@ namespace ErsatzTV.Application.ProgramSchedules; public abstract class ProgramScheduleItemCommandBase { - protected static Task> ProgramScheduleMustExist( + protected static Task> ProgramScheduleMustExist( TvContext dbContext, int programScheduleId, CancellationToken cancellationToken) => @@ -17,7 +18,7 @@ public abstract class ProgramScheduleItemCommandBase .Include(ps => ps.Items) .Include(ps => ps.Playouts) .SelectOneAsync(ps => ps.Id, ps => ps.Id == programScheduleId, cancellationToken) - .Map(o => o.ToValidation("[ProgramScheduleId] does not exist.")); + .Map(identity); protected static async Task> FillerConfigurationMustBeValid( TvContext dbContext, @@ -352,9 +353,11 @@ public abstract class ProgramScheduleItemCommandBase _ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}") }; + result.ProgramScheduleItemWatermarks = []; + result.ProgramScheduleItemGraphicsElements = []; + foreach (int watermarkId in item.WatermarkIds) { - result.ProgramScheduleItemWatermarks ??= []; result.ProgramScheduleItemWatermarks.Add( new ProgramScheduleItemWatermark { @@ -365,7 +368,6 @@ public abstract class ProgramScheduleItemCommandBase foreach (int graphicsElementId in item.GraphicsElementIds) { - result.ProgramScheduleItemGraphicsElements ??= []; result.ProgramScheduleItemGraphicsElements.Add( new ProgramScheduleItemGraphicsElement { diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs index 9e60fc29a..b149a2bec 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs @@ -2,6 +2,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -19,8 +20,16 @@ public class ReplaceProgramScheduleItemsHandler( CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken)); + Option maybeProgramSchedule = + await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken); + return await maybeProgramSchedule.Match( + Some: async programSchedule => + { + Validation validation = await Validate(dbContext, request, programSchedule); + return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken)); + }, + None: () => Task.FromResult>>( + new NotFoundError("[ProgramScheduleId] does not exist."))); } private async Task> PersistItems( @@ -50,15 +59,20 @@ public class ReplaceProgramScheduleItemsHandler( return programSchedule.Items.Map(ProjectToViewModel); } - private static Task> Validate( + private static async Task> Validate( TvContext dbContext, ReplaceProgramScheduleItems request, - CancellationToken cancellationToken) => - ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken) - .BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule)) - .BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule)) - .BindT(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule)) - .BindT(programSchedule => FillerConfigurationsMustBeValid(dbContext, request, programSchedule)); + ProgramSchedule programSchedule) + { + Validation validation = PlayoutModesMustBeValid(request, programSchedule) + .Bind(programSchedule => CollectionTypesMustBeValid(request, programSchedule)) + .Bind(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule)); + + return await validation.ToEither().Match( + Left: error => Task.FromResult>( + Fail(error)), + Right: validProgramSchedule => FillerConfigurationsMustBeValid(dbContext, request, validProgramSchedule)); + } private static Validation PlayoutModesMustBeValid( ReplaceProgramScheduleItems request, diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs index ba4c2ce95..9198db1a3 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs @@ -2,6 +2,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -20,8 +21,16 @@ public class UpdateProgramScheduleHandler( CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request)); + Option maybeProgramSchedule = + await ProgramScheduleMustExist(dbContext, request, cancellationToken); + return await maybeProgramSchedule.Match( + Some: async programSchedule => + { + Validation validation = await Validate(dbContext, request, programSchedule, cancellationToken); + return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request)); + }, + None: () => Task.FromResult>( + new NotFoundError("Schedule does not exist"))); } private async Task ApplyUpdateRequest( @@ -66,17 +75,17 @@ public class UpdateProgramScheduleHandler( private static async Task> Validate( TvContext dbContext, UpdateProgramSchedule request, + ProgramSchedule programSchedule, CancellationToken cancellationToken) => - (await ProgramScheduleMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request, cancellationToken)) - .Apply((programSchedule, _) => programSchedule); + (await ValidateName(dbContext, request, cancellationToken)).Map(_ => programSchedule); - private static Task> ProgramScheduleMustExist( + private static Task> ProgramScheduleMustExist( TvContext dbContext, UpdateProgramSchedule request, CancellationToken cancellationToken) => dbContext.ProgramSchedules .SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken) - .Map(o => o.ToValidation("Schedule does not exist")); + .Map(identity); private static async Task> ValidateName( TvContext dbContext, diff --git a/ErsatzTV.Tests/Application/ProgramSchedules/ProgramScheduleHandlerTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/ProgramScheduleHandlerTests.cs new file mode 100644 index 000000000..f9224b8f6 --- /dev/null +++ b/ErsatzTV.Tests/Application/ProgramSchedules/ProgramScheduleHandlerTests.cs @@ -0,0 +1,217 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.ProgramSchedules; + +[TestFixture] +public class ProgramScheduleHandlerTests +{ + private InMemoryTvContext _db = null!; + private ChannelWriter _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = System.Threading.Channels.Channel.CreateUnbounded().Writer; + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Update_Should_Return_NotFoundError_When_Schedule_Missing() + { + var handler = new UpdateProgramScheduleHandler(_db.Factory, _worker); + + Either result = + await handler.Handle(MakeUpdate(999, "Missing"), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_NotFoundError_When_Schedule_Missing() + { + var handler = new DeleteProgramScheduleHandler(_db.Factory); + + Either result = + await handler.Handle(new DeleteProgramSchedule(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task AddItem_Should_Return_NotFoundError_When_Schedule_Missing() + { + var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker); + + Either result = + await handler.Handle(MakeAdd(999, CollectionType.SearchQuery), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task AddItem_Should_Return_ValidationError_When_Collection_Type_Is_Invalid() + { + int scheduleId = await SeedSchedule(); + var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker); + + Either result = + await handler.Handle(MakeAdd(scheduleId, CollectionType.Collection), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("[Collection] is required"); + } + + [Test] + public async Task ReplaceItems_Should_Return_NotFoundError_When_Schedule_Missing() + { + var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); + + Either> result = + await handler.Handle( + new ReplaceProgramScheduleItems(999, [MakeReplace(0)]), + CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task DeleteItem_Should_Return_NotFoundError_When_Item_Missing() + { + int scheduleId = await SeedSchedule(); + var handler = new DeleteProgramScheduleItemHandler(_db.Factory, _worker); + + Either result = + await handler.Handle(new DeleteProgramScheduleItem(scheduleId, 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + private async Task SeedSchedule() + { + await using TvContext context = _db.CreateContext(); + var schedule = new ProgramSchedule + { + Name = "Handlers", + Items = [], + Playouts = [], + ProgramScheduleAlternates = [] + }; + context.ProgramSchedules.Add(schedule); + await context.SaveChangesAsync(); + return schedule.Id; + } + + private static UpdateProgramSchedule MakeUpdate(int scheduleId, string name) => + new( + scheduleId, + name, + KeepMultiPartEpisodesTogether: true, + TreatCollectionsAsShows: true, + ShuffleScheduleItems: false, + RandomStartPoint: false, + FixedStartTimeBehavior.Flexible); + + private static AddProgramScheduleItem MakeAdd(int scheduleId, CollectionType collectionType) => + new( + scheduleId, + StartType.Dynamic, + StartTime: null, + FixedStartTimeBehavior: null, + PlayoutMode.One, + collectionType, + CollectionId: null, + MultiCollectionId: null, + SmartCollectionId: null, + RerunCollectionId: null, + MediaItemId: null, + PlaylistId: null, + SearchTitle: "News", + SearchQuery: "news", + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + MarathonShuffleGroups: false, + MarathonShuffleItems: false, + MarathonBatchSize: null, + FillWithGroupMode.None, + MultipleMode.Count, + MultipleCount: "1", + PlayoutDuration: null, + TailMode.None, + DiscardToFillAttempts: null, + CustomTitle: null, + GuideMode.Normal, + PreRollFillerId: null, + MidRollFillerId: null, + PostRollFillerId: null, + TailFillerId: null, + FallbackFillerId: null, + WatermarkIds: [], + GraphicsElementIds: [], + PreferredAudioLanguageCode: null, + PreferredAudioTitle: null, + PreferredSubtitleLanguageCode: null, + SubtitleMode: null); + + private static ReplaceProgramScheduleItem MakeReplace(int index) + { + AddProgramScheduleItem add = MakeAdd(1, CollectionType.SearchQuery); + return new ReplaceProgramScheduleItem( + index, + add.StartType, + add.StartTime, + add.FixedStartTimeBehavior, + add.PlayoutMode, + add.CollectionType, + add.CollectionId, + add.MultiCollectionId, + add.SmartCollectionId, + add.RerunCollectionId, + add.MediaItemId, + add.PlaylistId, + add.SearchTitle, + add.SearchQuery, + add.PlaybackOrder, + add.MarathonGroupBy, + add.MarathonShuffleGroups, + add.MarathonShuffleItems, + add.MarathonBatchSize, + add.FillWithGroupMode, + add.MultipleMode, + add.MultipleCount, + add.PlayoutDuration, + add.TailMode, + add.DiscardToFillAttempts, + add.CustomTitle, + add.GuideMode, + add.PreRollFillerId, + add.MidRollFillerId, + add.PostRollFillerId, + add.TailFillerId, + add.FallbackFillerId, + add.WatermarkIds, + add.GraphicsElementIds, + add.PreferredAudioLanguageCode, + add.PreferredAudioTitle, + add.PreferredSubtitleLanguageCode, + add.SubtitleMode); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); +} diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 1f61a1166..252e8ace8 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -36,6 +36,20 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status404NotFound)] [TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.Update), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.Update), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.Delete), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.GetItems), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.AddItem), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.AddItem), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status404NotFound)] + [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status422UnprocessableEntity)] public void Api_Error_Response_Metadata_Should_Document_ProblemDetails( Type controllerType, string actionName, diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 2755a9a15..856184fd4 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -7,6 +7,22 @@ namespace ErsatzTV.Tests.Controllers; [TestFixture] public class OpenApiErrorResponseContractTests { + [Test] + public void Static_OpenApi_Should_Document_Schedule_Item_Discriminator_As_String_Enum() + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); + + JsonElement playoutMode = document.RootElement + .GetProperty("components") + .GetProperty("schemas") + .GetProperty("PlayoutMode"); + + playoutMode.GetProperty("type").GetString().ShouldBe("string"); + playoutMode.GetProperty("enum").EnumerateArray() + .Select(e => e.GetString()) + .ShouldContain("One"); + } + [TestCase("/api/channels/{id}", "get", "404")] [TestCase("/api/channels", "post", "404")] [TestCase("/api/channels", "post", "422")] @@ -33,6 +49,20 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/smart-collections/{id}", "put", "422")] [TestCase("/api/smart-collections/{id}", "delete", "404")] [TestCase("/api/smart-collections/{id}", "delete", "422")] + [TestCase("/api/schedules/{id}", "get", "404")] + [TestCase("/api/schedules", "post", "404")] + [TestCase("/api/schedules", "post", "422")] + [TestCase("/api/schedules/{id}", "put", "404")] + [TestCase("/api/schedules/{id}", "put", "422")] + [TestCase("/api/schedules/{id}", "delete", "404")] + [TestCase("/api/schedules/{id}", "delete", "422")] + [TestCase("/api/schedules/{id}/items", "get", "404")] + [TestCase("/api/schedules/{id}/items", "post", "404")] + [TestCase("/api/schedules/{id}/items", "post", "422")] + [TestCase("/api/schedules/{id}/items", "put", "404")] + [TestCase("/api/schedules/{id}/items", "put", "422")] + [TestCase("/api/schedules/{id}/items/{itemId}", "delete", "404")] + [TestCase("/api/schedules/{id}/items/{itemId}", "delete", "422")] public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( string path, string method, diff --git a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs new file mode 100644 index 000000000..4c0c11d36 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs @@ -0,0 +1,394 @@ +using System.Reflection; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Filters; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class ScheduleControllerTests +{ + private ScheduleController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new ScheduleController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(ScheduleController.GetAll), "GET", "/api/schedules"); + ShouldHaveActionRoute(nameof(ScheduleController.GetById), "GET", "/api/schedules/{id:int}"); + ShouldHaveActionRoute(nameof(ScheduleController.Create), "POST", "/api/schedules"); + ShouldHaveActionRoute(nameof(ScheduleController.Update), "PUT", "/api/schedules/{id:int}"); + ShouldHaveActionRoute(nameof(ScheduleController.Delete), "DELETE", "/api/schedules/{id:int}"); + ShouldHaveActionRoute(nameof(ScheduleController.GetItems), "GET", "/api/schedules/{id:int}/items"); + ShouldHaveActionRoute(nameof(ScheduleController.AddItem), "POST", "/api/schedules/{id:int}/items"); + ShouldHaveActionRoute(nameof(ScheduleController.ReplaceItems), "PUT", "/api/schedules/{id:int}/items"); + ShouldHaveActionRoute( + nameof(ScheduleController.DeleteItem), + "DELETE", + "/api/schedules/{id:int}/items/{itemId:int}"); + } + + [Test] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter() + { + ServiceFilterAttribute? filter = typeof(ScheduleController) + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull("ScheduleController must carry ApiKeyAuthorizationFilter at the class level"); + } + + [Test] + public void Update_Should_Use_Stable_Update_Request_Dto() + { + MethodInfo action = typeof(ScheduleController).GetMethod(nameof(ScheduleController.Update)) + ?? throw new AssertionException($"Missing action {nameof(ScheduleController.Update)}"); + + action.GetParameters()[1].ParameterType.ShouldBe(typeof(UpdateScheduleRequest)); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateProgramScheduleResult(5))); + ProgramScheduleViewModel vm = MakeSchedule(5, "Daily"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/schedules/5"); + created.Value.ShouldBe(vm); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create(MakeScheduleRequest(string.Empty), CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + problem.Title.ShouldBe("Validation failed"); + } + + [Test] + public async Task Create_Should_Map_Request_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateProgramScheduleResult(5))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeSchedule(5, "Daily"))); + + await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => + c.Name == "Daily" && + c.KeepMultiPartEpisodesTogether && + c.TreatCollectionsAsShows && + c.ShuffleScheduleItems && + c.RandomStartPoint && + c.FixedStartTimeBehavior == FixedStartTimeBehavior.Flexible), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_200_And_Map_Route_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new UpdateProgramScheduleResult(7))); + ProgramScheduleViewModel vm = MakeSchedule(7, "Updated"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Update(7, MakeUpdateScheduleRequest("Updated"), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => c.ProgramScheduleId == 7 && c.Name == "Updated"), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_404_For_NotFoundError_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Update(99, MakeUpdateScheduleRequest("Missing"), CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(3, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + ProgramScheduleViewModel vm = MakeSchedule(4, "Daily"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetItems_Should_Return_200_With_Items() + { + List items = [MakeOneItem(11)]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeSchedule(4, "Daily"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(items); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(items); + } + + [Test] + public async Task GetItems_Should_Return_404_When_Schedule_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task AddItem_Should_Return_201_With_Location_And_Map_Route_Id() + { + ProgramScheduleItemViewModel item = MakeOneItem(12); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(item)); + + IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.One), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/schedules/4/items/12"); + created.Value.ShouldBe(item); + await _mediator.Received(1).Send( + Arg.Is(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One), + Arg.Any()); + } + + [Test] + public async Task AddItem_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.Duration), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplaceItems_Should_Return_200_With_Items_And_Map_Route_Id() + { + List items = [MakeOneItem(21), MakeOneItem(22)]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right>(items)); + + IActionResult result = await _controller.ReplaceItems( + 4, + new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(items); + await _mediator.Received(1).Send( + Arg.Is(c => + c.ProgramScheduleId == 4 && + c.Items.Count == 2 && + c.Items[0].Index == 0 && + c.Items[1].PlayoutMode == PlayoutMode.Multiple), + Arg.Any()); + } + + [Test] + public async Task DeleteItem_Should_Return_204_And_Map_Route_Ids() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.DeleteItem(4, 12, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.ProgramScheduleId == 4 && c.ProgramScheduleItemId == 12), + Arg.Any()); + } + + private static CreateScheduleRequest MakeScheduleRequest(string name) => + new( + name, + KeepMultiPartEpisodesTogether: true, + TreatCollectionsAsShows: true, + ShuffleScheduleItems: true, + RandomStartPoint: true, + FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible); + + private static UpdateScheduleRequest MakeUpdateScheduleRequest(string name) => + new( + name, + KeepMultiPartEpisodesTogether: true, + TreatCollectionsAsShows: true, + ShuffleScheduleItems: true, + RandomStartPoint: true, + FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible); + + private static ScheduleItemRequest MakeItemRequest(PlayoutMode playoutMode) => + new( + StartType.Dynamic, + StartTime: null, + FixedStartTimeBehavior: null, + playoutMode, + CollectionType.SearchQuery, + CollectionId: null, + MultiCollectionId: null, + SmartCollectionId: null, + RerunCollectionId: null, + MediaItemId: null, + PlaylistId: null, + SearchTitle: "News", + SearchQuery: "news", + PlaybackOrder: PlaybackOrder.Shuffle, + MarathonGroupBy: MarathonGroupBy.None, + MarathonShuffleGroups: false, + MarathonShuffleItems: false, + MarathonBatchSize: null, + FillWithGroupMode: FillWithGroupMode.None, + MultipleMode: MultipleMode.Count, + MultipleCount: "2", + PlayoutDuration: TimeSpan.FromMinutes(30), + TailMode: TailMode.None, + DiscardToFillAttempts: 3, + CustomTitle: null, + GuideMode: GuideMode.Normal, + PreRollFillerId: null, + MidRollFillerId: null, + PostRollFillerId: null, + TailFillerId: null, + FallbackFillerId: null, + WatermarkIds: [], + GraphicsElementIds: [], + PreferredAudioLanguageCode: null, + PreferredAudioTitle: null, + PreferredSubtitleLanguageCode: null, + SubtitleMode: null); + + private static ProgramScheduleViewModel MakeSchedule(int id, string name) => + new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible); + + private static ProgramScheduleItemOneViewModel MakeOneItem(int id) => + new( + id, + 0, + StartType.Dynamic, + null, + null, + CollectionType.SearchQuery, + null, + null, + null, + null, + null, + null, + "News", + "news", + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(ScheduleController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Integration/ScheduleItemTptIntegrationTests.cs b/ErsatzTV.Tests/Integration/ScheduleItemTptIntegrationTests.cs new file mode 100644 index 000000000..afd7213c2 --- /dev/null +++ b/ErsatzTV.Tests/Integration/ScheduleItemTptIntegrationTests.cs @@ -0,0 +1,188 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Integration; + +[TestFixture] +public class ScheduleItemTptIntegrationTests +{ + private InMemoryTvContext _db = null!; + private ChannelWriter _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = System.Threading.Channels.Channel.CreateUnbounded().Writer; + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task AddProgramScheduleItem_Should_Create_Tpt_Subtype_Row_For_Each_PlayoutMode() + { + int scheduleId = await SeedSchedule(); + var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker); + + foreach (PlayoutMode playoutMode in new[] + { + PlayoutMode.One, + PlayoutMode.Multiple, + PlayoutMode.Flood, + PlayoutMode.Duration + }) + { + Either result = + await handler.Handle(MakeAdd(scheduleId, playoutMode), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + } + + await using TvContext context = _db.CreateContext(); + (await CountRows(context, "ProgramScheduleOneItem")).ShouldBe(1); + (await CountRows(context, "ProgramScheduleMultipleItem")).ShouldBe(1); + (await CountRows(context, "ProgramScheduleFloodItem")).ShouldBe(1); + (await CountRows(context, "ProgramScheduleDurationItem")).ShouldBe(1); + } + + [Test] + public async Task ReplaceProgramScheduleItems_Should_Create_Tpt_Subtype_Rows_For_Replaced_Items() + { + int scheduleId = await SeedSchedule(); + var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); + + Either> result = + await handler.Handle( + new ReplaceProgramScheduleItems( + scheduleId, + [ + MakeReplace(0, PlayoutMode.One), + MakeReplace(1, PlayoutMode.Multiple), + MakeReplace(2, PlayoutMode.Flood), + MakeReplace(3, PlayoutMode.Duration) + ]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await using TvContext context = _db.CreateContext(); + (await CountRows(context, "ProgramScheduleOneItem")).ShouldBe(1); + (await CountRows(context, "ProgramScheduleMultipleItem")).ShouldBe(1); + (await CountRows(context, "ProgramScheduleFloodItem")).ShouldBe(1); + (await CountRows(context, "ProgramScheduleDurationItem")).ShouldBe(1); + } + + private async Task SeedSchedule() + { + await using TvContext context = _db.CreateContext(); + var schedule = new ProgramSchedule + { + Name = "Integration", + Items = [], + Playouts = [], + ProgramScheduleAlternates = [] + }; + context.ProgramSchedules.Add(schedule); + await context.SaveChangesAsync(); + return schedule.Id; + } + + private static AddProgramScheduleItem MakeAdd(int scheduleId, PlayoutMode playoutMode) => + new( + scheduleId, + StartType.Dynamic, + StartTime: null, + FixedStartTimeBehavior: null, + playoutMode, + CollectionType.SearchQuery, + CollectionId: null, + MultiCollectionId: null, + SmartCollectionId: null, + RerunCollectionId: null, + MediaItemId: null, + PlaylistId: null, + SearchTitle: "News", + SearchQuery: "news", + PlaybackOrder: PlaybackOrder.Shuffle, + MarathonGroupBy: MarathonGroupBy.None, + MarathonShuffleGroups: false, + MarathonShuffleItems: false, + MarathonBatchSize: null, + FillWithGroupMode: FillWithGroupMode.None, + MultipleMode: MultipleMode.Count, + MultipleCount: "2", + PlayoutDuration: TimeSpan.FromMinutes(30), + TailMode: TailMode.None, + DiscardToFillAttempts: 3, + CustomTitle: null, + GuideMode: GuideMode.Normal, + PreRollFillerId: null, + MidRollFillerId: null, + PostRollFillerId: null, + TailFillerId: null, + FallbackFillerId: null, + WatermarkIds: [], + GraphicsElementIds: [], + PreferredAudioLanguageCode: null, + PreferredAudioTitle: null, + PreferredSubtitleLanguageCode: null, + SubtitleMode: null); + + private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode) + { + AddProgramScheduleItem add = MakeAdd(1, playoutMode); + return new ReplaceProgramScheduleItem( + index, + add.StartType, + add.StartTime, + add.FixedStartTimeBehavior, + add.PlayoutMode, + add.CollectionType, + add.CollectionId, + add.MultiCollectionId, + add.SmartCollectionId, + add.RerunCollectionId, + add.MediaItemId, + add.PlaylistId, + add.SearchTitle, + add.SearchQuery, + add.PlaybackOrder, + add.MarathonGroupBy, + add.MarathonShuffleGroups, + add.MarathonShuffleItems, + add.MarathonBatchSize, + add.FillWithGroupMode, + add.MultipleMode, + add.MultipleCount, + add.PlayoutDuration, + add.TailMode, + add.DiscardToFillAttempts, + add.CustomTitle, + add.GuideMode, + add.PreRollFillerId, + add.MidRollFillerId, + add.PostRollFillerId, + add.TailFillerId, + add.FallbackFillerId, + add.WatermarkIds, + add.GraphicsElementIds, + add.PreferredAudioLanguageCode, + add.PreferredAudioTitle, + add.PreferredSubtitleLanguageCode, + add.SubtitleMode); + } + + private static Task CountRows(TvContext context, string tableName) => + context.Database.SqlQueryRaw($"SELECT COUNT(*) AS Value FROM {tableName}").SingleAsync(); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs new file mode 100644 index 000000000..837ee332e --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs @@ -0,0 +1,22 @@ +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateScheduleRequest( + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior) +{ + public CreateProgramSchedule ToCreateCommand() => + new( + Name, + KeepMultiPartEpisodesTogether, + TreatCollectionsAsShows, + ShuffleScheduleItems, + RandomStartPoint, + FixedStartTimeBehavior); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplaceScheduleItemsRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplaceScheduleItemsRequest.cs new file mode 100644 index 000000000..40a8f6864 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplaceScheduleItemsRequest.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Application.ProgramSchedules; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplaceScheduleItemsRequest(List Items) +{ + public ReplaceProgramScheduleItems ToCommand(int scheduleId) => + new( + scheduleId, + (Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs new file mode 100644 index 000000000..9a3794106 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs @@ -0,0 +1,127 @@ +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ScheduleItemRequest( + StartType StartType, + TimeSpan? StartTime, + FixedStartTimeBehavior? FixedStartTimeBehavior, + PlayoutMode PlayoutMode, + CollectionType CollectionType, + int? CollectionId, + int? MultiCollectionId, + int? SmartCollectionId, + int? RerunCollectionId, + int? MediaItemId, + int? PlaylistId, + string SearchTitle, + string SearchQuery, + PlaybackOrder PlaybackOrder, + MarathonGroupBy MarathonGroupBy, + bool MarathonShuffleGroups, + bool MarathonShuffleItems, + int? MarathonBatchSize, + FillWithGroupMode FillWithGroupMode, + MultipleMode MultipleMode, + string MultipleCount, + TimeSpan? PlayoutDuration, + TailMode TailMode, + int? DiscardToFillAttempts, + string CustomTitle, + GuideMode GuideMode, + int? PreRollFillerId, + int? MidRollFillerId, + int? PostRollFillerId, + int? TailFillerId, + int? FallbackFillerId, + List WatermarkIds, + List GraphicsElementIds, + string PreferredAudioLanguageCode, + string PreferredAudioTitle, + string PreferredSubtitleLanguageCode, + ChannelSubtitleMode? SubtitleMode) +{ + public AddProgramScheduleItem ToAddCommand(int scheduleId) => + new( + scheduleId, + StartType, + StartTime, + FixedStartTimeBehavior, + PlayoutMode, + CollectionType, + CollectionId, + MultiCollectionId, + SmartCollectionId, + RerunCollectionId, + MediaItemId, + PlaylistId, + SearchTitle, + SearchQuery, + PlaybackOrder, + MarathonGroupBy, + MarathonShuffleGroups, + MarathonShuffleItems, + MarathonBatchSize, + FillWithGroupMode, + MultipleMode, + MultipleCount, + PlayoutDuration, + TailMode, + DiscardToFillAttempts, + CustomTitle, + GuideMode, + PreRollFillerId, + MidRollFillerId, + PostRollFillerId, + TailFillerId, + FallbackFillerId, + WatermarkIds ?? [], + GraphicsElementIds ?? [], + PreferredAudioLanguageCode, + PreferredAudioTitle, + PreferredSubtitleLanguageCode, + SubtitleMode); + + public ReplaceProgramScheduleItem ToReplaceCommand(int index) => + new( + index, + StartType, + StartTime, + FixedStartTimeBehavior, + PlayoutMode, + CollectionType, + CollectionId, + MultiCollectionId, + SmartCollectionId, + RerunCollectionId, + MediaItemId, + PlaylistId, + SearchTitle, + SearchQuery, + PlaybackOrder, + MarathonGroupBy, + MarathonShuffleGroups, + MarathonShuffleItems, + MarathonBatchSize, + FillWithGroupMode, + MultipleMode, + MultipleCount, + PlayoutDuration, + TailMode, + DiscardToFillAttempts, + CustomTitle, + GuideMode, + PreRollFillerId, + MidRollFillerId, + PostRollFillerId, + TailFillerId, + FallbackFillerId, + WatermarkIds ?? [], + GraphicsElementIds ?? [], + PreferredAudioLanguageCode, + PreferredAudioTitle, + PreferredSubtitleLanguageCode, + SubtitleMode); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs new file mode 100644 index 000000000..e89773239 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs @@ -0,0 +1,23 @@ +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateScheduleRequest( + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior) +{ + public UpdateProgramSchedule ToCommand(int id) => + new( + id, + Name, + KeepMultiPartEpisodesTogether, + TreatCollectionsAsShows, + ShuffleScheduleItems, + RandomStartPoint, + FixedStartTimeBehavior); +} diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs new file mode 100644 index 000000000..b66f2f3ce --- /dev/null +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -0,0 +1,167 @@ +using System.ComponentModel.DataAnnotations; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Extensions; +using ErsatzTV.Filters; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] +public class ScheduleController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/schedules")] + [Tags("Schedules")] + [EndpointSummary("Get all schedules")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllProgramSchedules(), cancellationToken); + + [HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")] + [Tags("Schedules")] + [EndpointSummary("Get a schedule by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken); + return result.ToGetResult(); + } + + [HttpPost("/api/schedules")] + [Tags("Schedules")] + [EndpointSummary("Create a schedule")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateScheduleRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCreateCommand(), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async created => + { + Option schedule = + await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken); + return schedule.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/schedules/{vm.Id}", vm), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpPut("/api/schedules/{id:int}")] + [Tags("Schedules")] + [EndpointSummary("Update a schedule")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(ProgramScheduleViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Update( + int id, + [Required] [FromBody] UpdateScheduleRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option schedule = + await mediator.Send(new GetProgramScheduleById(id), cancellationToken); + return schedule.Match( + Some: vm => (IActionResult)new OkObjectResult(vm), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpDelete("/api/schedules/{id:int}")] + [Tags("Schedules")] + [EndpointSummary("Delete a schedule")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteProgramSchedule(id), cancellationToken); + return result.ToDeletedResult(); + } + + [HttpGet("/api/schedules/{id:int}/items")] + [Tags("Schedules")] + [EndpointSummary("Get schedule items")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetItems(int id, CancellationToken cancellationToken) + { + Option schedule = await mediator.Send(new GetProgramScheduleById(id), cancellationToken); + if (schedule.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List items = + await mediator.Send(new GetProgramScheduleItems(id), cancellationToken); + return new OkObjectResult(items); + } + + [HttpPost("/api/schedules/{id:int}/items")] + [Tags("Schedules")] + [EndpointSummary("Add a schedule item")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task AddItem( + int id, + [Required] [FromBody] ScheduleItemRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToAddCommand(id), cancellationToken); + return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item); + } + + [HttpPut("/api/schedules/{id:int}/items")] + [Tags("Schedules")] + [EndpointSummary("Replace schedule items")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplaceItems( + int id, + [Required] [FromBody] ReplaceScheduleItemsRequest request, + CancellationToken cancellationToken) + { + Either> result = + await mediator.Send(request.ToCommand(id), cancellationToken); + return result.ToUpdatedResult(); + } + + [HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")] + [Tags("Schedules")] + [EndpointSummary("Delete a schedule item")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task DeleteItem(int id, int itemId, CancellationToken cancellationToken) + { + Either result = + await mediator.Send(new DeleteProgramScheduleItem(id, itemId), cancellationToken); + return result.ToDeletedResult(); + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 8ffa20081..0f350fc88 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using System.Reflection; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading.Channels; using BlazorSortable; @@ -118,6 +119,35 @@ public class Startup private IWebHostEnvironment CurrentEnvironment { get; } + private static void UseStringEnumSchemas(OpenApiDocument document) + { + if (document.Components?.Schemas is null) + { + return; + } + + Dictionary enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes() + .Where(type => type.IsEnum) + .GroupBy(type => type.Name) + .ToDictionary(group => group.Key, group => group.First()); + + foreach ((string schemaName, IOpenApiSchema schema) in document.Components.Schemas) + { + if (schema is not OpenApiSchema openApiSchema || + !enumTypes.TryGetValue(schemaName, out Type enumType)) + { + continue; + } + + openApiSchema.Type = JsonSchemaType.String; + openApiSchema.Format = null; + openApiSchema.Enum = Enum.GetNames(enumType) + .Select(name => JsonValue.Create(name)) + .Cast() + .ToList(); + } + } + [SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] public void ConfigureServices(IServiceCollection services) { @@ -133,7 +163,17 @@ public class Startup services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder)); - services.AddOpenApi("v1", options => { options.ShouldInclude += a => a.GroupName == "general"; }); + services.AddOpenApi( + "v1", + options => + { + options.ShouldInclude += a => a.GroupName == "general"; + options.AddDocumentTransformer((document, _, _) => + { + UseStringEnumSchemas(document); + return Task.CompletedTask; + }); + }); services.AddOpenApi( "scripted-schedule-tagged", diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 018f82046..4da969587 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -1230,6 +1230,726 @@ } } }, + "/api/schedules": { + "get": { + "tags": [ + "Schedules" + ], + "summary": "Get all schedules", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Schedules" + ], + "summary": "Create a schedule", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + } + } + }, + "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/schedules/{id}": { + "get": { + "tags": [ + "Schedules" + ], + "summary": "Get a schedule by id", + "operationId": "GetScheduleById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Schedules" + ], + "summary": "Update a schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleViewModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Schedules" + ], + "summary": "Delete a schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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/schedules/{id}/items": { + "get": { + "tags": [ + "Schedules" + ], + "summary": "Get schedule items", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "post": { + "tags": [ + "Schedules" + ], + "summary": "Add a schedule item", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ScheduleItemRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleItemRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleItemRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ScheduleItemRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Schedules" + ], + "summary": "Replace schedule items", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceScheduleItemsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceScheduleItemsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceScheduleItemsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceScheduleItemsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + } + } + } + }, + "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/schedules/{id}/items/{itemId}": { + "delete": { + "tags": [ + "Schedules" + ], + "summary": "Delete a schedule item", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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/sessions": { "get": { "tags": [ @@ -1837,16 +2557,32 @@ } }, "ChannelIdleBehavior": { - "type": "integer" + "enum": [ + "StopOnDisconnect", + "KeepRunning" + ], + "type": "string" }, "ChannelMusicVideoCreditsMode": { - "type": "integer" + "enum": [ + "None", + "GenerateSubtitles" + ], + "type": "string" }, "ChannelPlayoutMode": { - "type": "integer" + "enum": [ + "Continuous", + "OnDemand" + ], + "type": "string" }, "ChannelPlayoutSource": { - "type": "integer" + "enum": [ + "Generated", + "Mirror" + ], + "type": "string" }, "ChannelResponseModel": { "required": [ @@ -1896,16 +2632,34 @@ } }, "ChannelSongVideoMode": { - "type": "integer" + "enum": [ + "Default", + "WithProgress" + ], + "type": "string" }, "ChannelStreamSelectorMode": { - "type": "integer" + "enum": [ + "Default", + "Custom", + "Troubleshooting" + ], + "type": "string" }, "ChannelSubtitleMode": { - "type": "integer" + "enum": [ + "None", + "Forced", + "Default", + "Any" + ], + "type": "string" }, "ChannelTranscodeMode": { - "type": "integer" + "enum": [ + "OnDemand" + ], + "type": "string" }, "ChannelViewModel": { "required": [ @@ -2086,8 +2840,46 @@ } } }, + "ChannelWatermarkImageSource": { + "enum": [ + "Custom", + "ChannelLogo", + "Resource" + ], + "type": "string" + }, + "ChannelWatermarkMode": { + "enum": [ + "None", + "Permanent", + "Intermittent", + "OpacityExpression" + ], + "type": "string" + }, "CollectionType": { - "type": "integer" + "enum": [ + "Collection", + "TelevisionShow", + "TelevisionSeason", + "Artist", + "MultiCollection", + "SmartCollection", + "Playlist", + "RerunFirstRun", + "RerunRerun", + "SearchQuery", + "Movie", + "Episode", + "MusicVideo", + "OtherVideo", + "Song", + "Image", + "RemoteStream", + "FakeCollection", + "FakePlaylistItem" + ], + "type": "string" }, "CombinedVersion": { "required": [ @@ -2443,6 +3235,40 @@ } } }, + "CreateScheduleRequest": { + "required": [ + "name", + "keepMultiPartEpisodesTogether", + "treatCollectionsAsShows", + "shuffleScheduleItems", + "randomStartPoint", + "fixedStartTimeBehavior" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "keepMultiPartEpisodesTogether": { + "type": "boolean" + }, + "treatCollectionsAsShows": { + "type": "boolean" + }, + "shuffleScheduleItems": { + "type": "boolean" + }, + "randomStartPoint": { + "type": "boolean" + }, + "fixedStartTimeBehavior": { + "$ref": "#/components/schemas/FixedStartTimeBehavior" + } + } + }, "CreateSmartCollectionRequest": { "required": [ "name", @@ -2615,13 +3441,15 @@ "Ac3", "AacLatm", "Copy" - ] + ], + "type": "string" }, "FFmpegProfileBitDepth": { "enum": [ "EightBit", "TenBit" - ] + ], + "type": "string" }, "FFmpegProfileTonemapAlgorithm": { "enum": [ @@ -2631,7 +3459,8 @@ "Reinhard", "Mobius", "Hable" - ] + ], + "type": "string" }, "FFmpegProfileVideoFormat": { "enum": [ @@ -2641,10 +3470,192 @@ "Mpeg2Video", "Av1", "Copy" - ] + ], + "type": "string" + }, + "FillerKind": { + "enum": [ + "None", + "PreRoll", + "MidRoll", + "PostRoll", + "Tail", + "Fallback", + "GuideMode", + "DecoDefault" + ], + "type": "string" + }, + "FillerMode": { + "enum": [ + "None", + "Duration", + "Count", + "Pad", + "RandomCount" + ], + "type": "string" + }, + "FillerPresetViewModel": { + "required": [ + "id", + "name", + "fillerKind", + "fillerMode", + "duration", + "count", + "padToNearestMinute", + "allowWatermarks", + "collectionType", + "collectionId", + "mediaItemId", + "multiCollectionId", + "smartCollectionId", + "playlist", + "expression", + "useChaptersAsMediaItems" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "fillerKind": { + "$ref": "#/components/schemas/FillerKind" + }, + "fillerMode": { + "$ref": "#/components/schemas/FillerMode" + }, + "duration": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "count": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "padToNearestMinute": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "allowWatermarks": { + "type": "boolean" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlist": { + "$ref": "#/components/schemas/PlaylistViewModel" + }, + "expression": { + "type": [ + "null", + "string" + ] + }, + "useChaptersAsMediaItems": { + "type": "boolean" + } + } + }, + "FillWithGroupMode": { + "enum": [ + "None", + "FillWithOrderedGroups", + "FillWithShuffledGroups" + ], + "type": "string" }, "FilterMode": { - "type": "integer" + "enum": [ + "HardwareIfPossible", + "Software" + ], + "type": "string" + }, + "FixedStartTimeBehavior": { + "enum": [ + "Strict", + "Flexible" + ], + "type": "string" + }, + "GraphicsElementViewModel": { + "required": [ + "id", + "name", + "fileName" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "fileName": { + "type": [ + "null", + "string" + ] + } + } + }, + "GuideMode": { + "enum": [ + "Normal", + "Filler" + ], + "type": "string" }, "HardwareAccelerationKind": { "enum": [ @@ -2656,7 +3667,8 @@ "Amf", "V4l2m2m", "Rkmpp" - ] + ], + "type": "string" }, "HlsSessionModel": { "required": [ @@ -2689,6 +3701,17 @@ } } }, + "MarathonGroupBy": { + "enum": [ + "None", + "Show", + "Season", + "Artist", + "Album", + "Director" + ], + "type": "string" + }, "MediaCollectionViewModel": { "required": [ "collectionType", @@ -2752,13 +3775,186 @@ } }, "MediaItemState": { - "type": "integer" + "enum": [ + "Normal", + "FileNotFound", + "Unavailable", + "RemoteOnly" + ], + "type": "string" + }, + "MultiCollectionItemViewModel": { + "required": [ + "multiCollectionId", + "collection", + "scheduleAsGroup", + "playbackOrder" + ], + "type": "object", + "properties": { + "multiCollectionId": { + "type": "integer", + "format": "int32" + }, + "collection": { + "$ref": "#/components/schemas/MediaCollectionViewModel" + }, + "scheduleAsGroup": { + "type": "boolean" + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, + "MultiCollectionSmartItemViewModel": { + "required": [ + "multiCollectionId", + "smartCollection", + "scheduleAsGroup", + "playbackOrder" + ], + "type": "object", + "properties": { + "multiCollectionId": { + "type": "integer", + "format": "int32" + }, + "smartCollection": { + "$ref": "#/components/schemas/SmartCollectionViewModel" + }, + "scheduleAsGroup": { + "type": "boolean" + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, + "MultiCollectionViewModel": { + "required": [ + "id", + "name", + "items", + "smartItems" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/MultiCollectionItemViewModel" + } + }, + "smartItems": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/MultiCollectionSmartItemViewModel" + } + } + } + }, + "MultipleMode": { + "enum": [ + "Count", + "CollectionSize", + "PlaylistItemSize", + "MultiEpisodeGroupSize" + ], + "type": "string" + }, + "NamedMediaItemViewModel": { + "required": [ + "mediaItemId", + "name" + ], + "type": "object", + "properties": { + "mediaItemId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } }, "NormalizeLoudnessMode": { "enum": [ "Off", "LoudNorm" - ] + ], + "type": "string" + }, + "PlaybackOrder": { + "enum": [ + "None", + "Chronological", + "Random", + "Shuffle", + "ShuffleInOrder", + "MultiEpisodeShuffle", + "SeasonEpisode", + "RandomRotation", + "Marathon" + ], + "type": "string" + }, + "PlaylistViewModel": { + "required": [ + "id", + "playlistGroupId", + "name", + "isSystem" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "playlistGroupId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "isSystem": { + "type": "boolean" + } + } + }, + "PlayoutMode": { + "enum": [ + "Flood", + "One", + "Multiple", + "Duration" + ], + "type": "string" }, "ProblemDetails": { "type": "object", @@ -2796,6 +3992,276 @@ } } }, + "ProgramScheduleItemViewModel": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "index": { + "type": "integer", + "format": "int32" + }, + "startType": { + "$ref": "#/components/schemas/StartType" + }, + "startTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "fixedStartTimeBehavior": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FixedStartTimeBehavior" + } + ] + }, + "playoutMode": { + "$ref": "#/components/schemas/PlayoutMode" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collection": { + "$ref": "#/components/schemas/MediaCollectionViewModel" + }, + "multiCollection": { + "$ref": "#/components/schemas/MultiCollectionViewModel" + }, + "smartCollection": { + "$ref": "#/components/schemas/SmartCollectionViewModel" + }, + "rerunCollection": { + "$ref": "#/components/schemas/RerunCollectionViewModel" + }, + "playlist": { + "$ref": "#/components/schemas/PlaylistViewModel" + }, + "mediaItem": { + "$ref": "#/components/schemas/NamedMediaItemViewModel" + }, + "searchTitle": { + "type": [ + "null", + "string" + ] + }, + "searchQuery": { + "type": [ + "null", + "string" + ] + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "marathonGroupBy": { + "$ref": "#/components/schemas/MarathonGroupBy" + }, + "marathonShuffleGroups": { + "type": "boolean" + }, + "marathonShuffleItems": { + "type": "boolean" + }, + "marathonBatchSize": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fillWithGroupMode": { + "$ref": "#/components/schemas/FillWithGroupMode" + }, + "customTitle": { + "type": [ + "null", + "string" + ] + }, + "guideMode": { + "$ref": "#/components/schemas/GuideMode" + }, + "preRollFiller": { + "$ref": "#/components/schemas/FillerPresetViewModel" + }, + "midRollFiller": { + "$ref": "#/components/schemas/FillerPresetViewModel" + }, + "postRollFiller": { + "$ref": "#/components/schemas/FillerPresetViewModel" + }, + "tailFiller": { + "$ref": "#/components/schemas/FillerPresetViewModel" + }, + "fallbackFiller": { + "$ref": "#/components/schemas/FillerPresetViewModel" + }, + "watermarks": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/WatermarkViewModel" + } + }, + "graphicsElements": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/GraphicsElementViewModel" + } + }, + "preferredAudioLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "preferredAudioTitle": { + "type": [ + "null", + "string" + ] + }, + "preferredSubtitleLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "subtitleMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelSubtitleMode" + } + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, + "ProgramScheduleViewModel": { + "required": [ + "id", + "name", + "keepMultiPartEpisodesTogether", + "treatCollectionsAsShows", + "shuffleScheduleItems", + "randomStartPoint", + "fixedStartTimeBehavior" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "keepMultiPartEpisodesTogether": { + "type": "boolean" + }, + "treatCollectionsAsShows": { + "type": "boolean" + }, + "shuffleScheduleItems": { + "type": "boolean" + }, + "randomStartPoint": { + "type": "boolean" + }, + "fixedStartTimeBehavior": { + "$ref": "#/components/schemas/FixedStartTimeBehavior" + } + } + }, + "ReplaceScheduleItemsRequest": { + "required": [ + "items" + ], + "type": "object", + "properties": { + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/ScheduleItemRequest" + } + } + } + }, + "RerunCollectionViewModel": { + "required": [ + "id", + "name", + "collectionType", + "collection", + "multiCollection", + "smartCollection", + "mediaItem", + "firstRunPlaybackOrder", + "rerunPlaybackOrder" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collection": { + "$ref": "#/components/schemas/MediaCollectionViewModel" + }, + "multiCollection": { + "$ref": "#/components/schemas/MultiCollectionViewModel" + }, + "smartCollection": { + "$ref": "#/components/schemas/SmartCollectionViewModel" + }, + "mediaItem": { + "$ref": "#/components/schemas/NamedMediaItemViewModel" + }, + "firstRunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "rerunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, "ResolutionViewModel": { "required": [ "id", @@ -2834,7 +4300,8 @@ "ScaleAndPad", "Stretch", "Crop" - ] + ], + "type": "string" }, "ScanShowRequest": { "required": [ @@ -2854,6 +4321,270 @@ } } }, + "ScheduleItemRequest": { + "required": [ + "startType", + "startTime", + "fixedStartTimeBehavior", + "playoutMode", + "collectionType", + "collectionId", + "multiCollectionId", + "smartCollectionId", + "rerunCollectionId", + "mediaItemId", + "playlistId", + "searchTitle", + "searchQuery", + "playbackOrder", + "marathonGroupBy", + "marathonShuffleGroups", + "marathonShuffleItems", + "marathonBatchSize", + "fillWithGroupMode", + "multipleMode", + "multipleCount", + "playoutDuration", + "tailMode", + "discardToFillAttempts", + "customTitle", + "guideMode", + "preRollFillerId", + "midRollFillerId", + "postRollFillerId", + "tailFillerId", + "fallbackFillerId", + "watermarkIds", + "graphicsElementIds", + "preferredAudioLanguageCode", + "preferredAudioTitle", + "preferredSubtitleLanguageCode", + "subtitleMode" + ], + "type": "object", + "properties": { + "startType": { + "$ref": "#/components/schemas/StartType" + }, + "startTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "fixedStartTimeBehavior": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FixedStartTimeBehavior" + } + ] + }, + "playoutMode": { + "$ref": "#/components/schemas/PlayoutMode" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "rerunCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "searchTitle": { + "type": [ + "null", + "string" + ] + }, + "searchQuery": { + "type": [ + "null", + "string" + ] + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "marathonGroupBy": { + "$ref": "#/components/schemas/MarathonGroupBy" + }, + "marathonShuffleGroups": { + "type": "boolean" + }, + "marathonShuffleItems": { + "type": "boolean" + }, + "marathonBatchSize": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fillWithGroupMode": { + "$ref": "#/components/schemas/FillWithGroupMode" + }, + "multipleMode": { + "$ref": "#/components/schemas/MultipleMode" + }, + "multipleCount": { + "type": [ + "null", + "string" + ] + }, + "playoutDuration": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "tailMode": { + "$ref": "#/components/schemas/TailMode" + }, + "discardToFillAttempts": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "customTitle": { + "type": [ + "null", + "string" + ] + }, + "guideMode": { + "$ref": "#/components/schemas/GuideMode" + }, + "preRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "midRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "postRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "tailFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fallbackFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "watermarkIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "graphicsElementIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "preferredAudioLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "preferredAudioTitle": { + "type": [ + "null", + "string" + ] + }, + "preferredSubtitleLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "subtitleMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelSubtitleMode" + } + ] + } + } + }, "SmartCollectionResponseModel": { "required": [ "id", @@ -2906,8 +4637,30 @@ } } }, + "StartType": { + "enum": [ + "Dynamic", + "Fixed" + ], + "type": "string" + }, "StreamingMode": { - "type": "integer" + "enum": [ + "TransportStream", + "HttpLiveStreamingDirect", + "HttpLiveStreamingSegmenter", + "TransportStreamHybrid" + ], + "type": "string" + }, + "TailMode": { + "enum": [ + "None", + "Offline", + "Slate", + "Filler" + ], + "type": "string" }, "UpdateChannelRequest": { "required": [ @@ -3256,6 +5009,40 @@ } } }, + "UpdateScheduleRequest": { + "required": [ + "name", + "keepMultiPartEpisodesTogether", + "treatCollectionsAsShows", + "shuffleScheduleItems", + "randomStartPoint", + "fixedStartTimeBehavior" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "keepMultiPartEpisodesTogether": { + "type": "boolean" + }, + "treatCollectionsAsShows": { + "type": "boolean" + }, + "shuffleScheduleItems": { + "type": "boolean" + }, + "randomStartPoint": { + "type": "boolean" + }, + "fixedStartTimeBehavior": { + "$ref": "#/components/schemas/FixedStartTimeBehavior" + } + } + }, "UpdateSmartCollectionRequest": { "required": [ "name", @@ -3284,7 +5071,99 @@ "i965", "RadeonSI", "Nouveau" - ] + ], + "type": "string" + }, + "WatermarkLocation": { + "type": "integer" + }, + "WatermarkSize": { + "type": "integer" + }, + "WatermarkViewModel": { + "required": [ + "id", + "image", + "name", + "mode", + "imageSource", + "location", + "size", + "width", + "horizontalMargin", + "verticalMargin", + "frequencyMinutes", + "durationSeconds", + "opacity", + "placeWithinSourceContent", + "opacityExpression", + "zIndex" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "image": { + "$ref": "#/components/schemas/ArtworkContentTypeModel" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "mode": { + "$ref": "#/components/schemas/ChannelWatermarkMode" + }, + "imageSource": { + "$ref": "#/components/schemas/ChannelWatermarkImageSource" + }, + "location": { + "$ref": "#/components/schemas/WatermarkLocation" + }, + "size": { + "$ref": "#/components/schemas/WatermarkSize" + }, + "width": { + "type": "number", + "format": "double" + }, + "horizontalMargin": { + "type": "number", + "format": "double" + }, + "verticalMargin": { + "type": "number", + "format": "double" + }, + "frequencyMinutes": { + "type": "integer", + "format": "int32" + }, + "durationSeconds": { + "type": "integer", + "format": "int32" + }, + "opacity": { + "type": "integer", + "format": "int32" + }, + "placeWithinSourceContent": { + "type": "boolean" + }, + "opacityExpression": { + "type": [ + "null", + "string" + ] + }, + "zIndex": { + "type": "integer", + "format": "int32" + } + } } } }, @@ -3310,6 +5189,9 @@ { "name": "Resolution" }, + { + "name": "Schedules" + }, { "name": "Sessions" }, -- 2.47.3 From b53b06c61519ffc9d47ad59732ee9a320d066521 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 29 Jun 2026 20:31:18 +0200 Subject: [PATCH 0003/1118] feat(api): add playout REST endpoints fixes #37 --- .../Commands/CreateClassicPlayoutHandler.cs | 42 ++- .../Playouts/Commands/DeletePlayoutHandler.cs | 3 +- .../Api/Playouts/PlayoutResponseModel.cs | 33 ++ .../Playouts/PlayoutHandlerTests.cs | 104 ++++++ .../ApiErrorResponseMetadataTests.cs | 5 + .../OpenApiErrorResponseContractTests.cs | 5 + .../Controllers/PlayoutControllerTests.cs | 208 ++++++++++++ .../PlayoutLifecycleIntegrationTests.cs | 130 ++++++++ ErsatzTV/Controllers/Api/PlayoutController.cs | 77 +++++ .../Api/Requests/CreatePlayoutRequest.cs | 8 + ErsatzTV/wwwroot/openapi/v1.json | 306 ++++++++++++++++++ 11 files changed, 905 insertions(+), 16 deletions(-) create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs create mode 100644 ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs create mode 100644 ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs create mode 100644 ErsatzTV/Controllers/Api/PlayoutController.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs diff --git a/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs index 8606b26a9..ca7a0ea5f 100644 --- a/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs @@ -2,6 +2,7 @@ using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -28,8 +29,23 @@ public class CreateClassicPlayoutHandler : IRequestHandler validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(playout => PersistPlayout(dbContext, playout)); + Option maybeChannel = await GetChannel(dbContext, request, cancellationToken); + return await maybeChannel.Match( + Some: async channel => + { + Option maybeProgramSchedule = + await GetProgramSchedule(dbContext, request, cancellationToken); + return await maybeProgramSchedule.Match( + Some: async programSchedule => + { + Validation validation = Validate(request, channel, programSchedule); + return await validation.Apply(playout => PersistPlayout(dbContext, playout)); + }, + None: () => Task.FromResult>( + new NotFoundError("Program schedule does not exist"))); + }, + None: () => Task.FromResult>( + new NotFoundError("Channel does not exist"))); } private async Task PersistPlayout(TvContext dbContext, Playout playout) @@ -46,12 +62,12 @@ public class CreateClassicPlayoutHandler : IRequestHandler> Validate( - TvContext dbContext, + private static Validation Validate( CreateClassicPlayout request, - CancellationToken cancellationToken) => - (await ValidateChannel(dbContext, request, cancellationToken), - await ValidateProgramSchedule(dbContext, request, cancellationToken), + Channel channel, + ProgramSchedule programSchedule) => + (ChannelMustNotHavePlayouts(channel), + ProgramScheduleMustHaveItems(programSchedule), ValidateScheduleKind(request)) .Apply((channel, programSchedule, scheduleKind) => new Playout { @@ -60,15 +76,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler> ValidateChannel( + private static Task> GetChannel( TvContext dbContext, CreateClassicPlayout createClassicPlayout, CancellationToken cancellationToken) => dbContext.Channels .Include(c => c.Playouts) - .SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken) - .Map(o => o.ToValidation("Channel does not exist")) - .BindT(ChannelMustNotHavePlayouts); + .SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken); private static Validation ChannelMustNotHavePlayouts(Channel channel) => Optional(channel.Playouts.Count) @@ -76,15 +90,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler channel) .ToValidation("Channel already has one playout"); - private static Task> ValidateProgramSchedule( + private static Task> GetProgramSchedule( TvContext dbContext, CreateClassicPlayout createClassicPlayout, CancellationToken cancellationToken) => dbContext.ProgramSchedules .Include(ps => ps.Items) - .SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken) - .Map(o => o.ToValidation("Program schedule does not exist")) - .BindT(ProgramScheduleMustHaveItems); + .SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken); private static Validation ProgramScheduleMustHaveItems( ProgramSchedule programSchedule) => diff --git a/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs index b47193413..c6434eaff 100644 --- a/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Notifications; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -45,6 +46,6 @@ public class DeletePlayoutHandler( return maybePlayout .Map(_ => Unit.Default) - .ToEither(BaseError.New($"Playout {request.PlayoutId} does not exist.")); + .ToEither(new NotFoundError($"Playout {request.PlayoutId} does not exist.")); } } diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs new file mode 100644 index 000000000..a80fd6ed0 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs @@ -0,0 +1,33 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutResponseModel( + int Id, + PlayoutScheduleKind ScheduleKind, + string ChannelName, + string ChannelNumber, + ChannelPlayoutMode PlayoutMode, + string ScheduleName, + string ScheduleFile, + TimeSpan? DailyRebuildTime) +{ + public static PlayoutResponseModel From( + int id, + PlayoutScheduleKind scheduleKind, + string channelName, + string channelNumber, + ChannelPlayoutMode playoutMode, + string scheduleName, + string scheduleFile, + TimeSpan? dailyRebuildTime) => + new( + id, + scheduleKind, + channelName, + channelNumber, + playoutMode, + scheduleName, + scheduleFile, + dailyRebuildTime); +} diff --git a/ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs b/ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs new file mode 100644 index 000000000..5377658e5 --- /dev/null +++ b/ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs @@ -0,0 +1,104 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; +using Unit = LanguageExt.Unit; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Playouts; + +[TestFixture] +public class PlayoutHandlerTests +{ + private InMemoryTvContext _db = null!; + private ChannelWriter _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded().Writer; + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task CreateClassic_Should_Return_NotFoundError_When_Channel_Missing() + { + var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory); + + Either result = + await handler.Handle(new CreateClassicPlayout(999, 1), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task CreateClassic_Should_Return_NotFoundError_When_Schedule_Missing() + { + int channelId = await SeedChannel(); + var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory); + + Either result = + await handler.Handle(new CreateClassicPlayout(channelId, 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_NotFoundError_When_Playout_Missing() + { + var handler = new DeletePlayoutHandler( + _worker, + _db.Factory, + new MockFileSystem(), + Substitute.For()); + + Either result = await handler.Handle(new DeletePlayout(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); + + private async Task SeedChannel() + { + await using TvContext context = _db.CreateContext(); + var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid()) + { + Number = "101", + SortNumber = 101, + Name = "Handler", + Group = string.Empty, + Categories = string.Empty, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + Playouts = [], + Artwork = [], + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + IsEnabled = true, + ShowInEpg = true + }; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + return channel.Id; + } +} diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 252e8ace8..14f15bf18 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -50,6 +50,11 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status404NotFound)] [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)] public void Api_Error_Response_Metadata_Should_Document_ProblemDetails( Type controllerType, string actionName, diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 856184fd4..83f378b0e 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -63,6 +63,11 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/schedules/{id}/items", "put", "422")] [TestCase("/api/schedules/{id}/items/{itemId}", "delete", "404")] [TestCase("/api/schedules/{id}/items/{itemId}", "delete", "422")] + [TestCase("/api/playouts/{id}", "get", "404")] + [TestCase("/api/playouts", "post", "404")] + [TestCase("/api/playouts", "post", "422")] + [TestCase("/api/playouts/{id}", "delete", "404")] + [TestCase("/api/playouts/{id}", "delete", "422")] public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( string path, string method, diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs new file mode 100644 index 000000000..7125d03dd --- /dev/null +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -0,0 +1,208 @@ +using System.Reflection; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Filters; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class PlayoutControllerTests +{ + private PlayoutController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new PlayoutController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}"); + ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts"); + ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}"); + } + + [Test] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter() + { + ServiceFilterAttribute? filter = typeof(PlayoutController) + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull("PlayoutController must carry ApiKeyAuthorizationFilter at the class level"); + } + + [Test] + public void Create_Should_Use_Stable_Request_Dto() + { + MethodInfo action = typeof(PlayoutController).GetMethod(nameof(PlayoutController.Create)) + ?? throw new AssertionException($"Missing action {nameof(PlayoutController.Create)}"); + + action.GetParameters()[0].ParameterType.ShouldBe(typeof(CreatePlayoutRequest)); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreatePlayoutResponse(9))); + PlayoutNameViewModel vm = MakePlayout(9); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/playouts/9"); + created.Value.ShouldBe(ToResponse(vm)); + } + + [Test] + public async Task Create_Should_Map_Request_To_Classic_Playout_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreatePlayoutResponse(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.ChannelId == 3 && c.ProgramScheduleId == 4), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_404_For_NotFoundError_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Create(new CreatePlayoutRequest(404, 4), CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + problem.Title.ShouldBe("Validation failed"); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9), + Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(404, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + PlayoutNameViewModel vm = MakePlayout(9); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(9, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(ToResponse(vm)); + } + + [Test] + public async Task GetById_Should_Return_404_For_None_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(9, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + } + + private static PlayoutNameViewModel MakePlayout(int id) => + new( + id, + PlayoutScheduleKind.Classic, + "Channel", + "101", + ChannelPlayoutMode.Continuous, + "Schedule", + string.Empty, + null, + new PlayoutBuildStatus()); + + private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => + PlayoutResponseModel.From( + vm.PlayoutId, + vm.ScheduleKind, + vm.ChannelName, + vm.ChannelNumber, + vm.PlayoutMode, + vm.ScheduleName, + vm.ScheduleFile, + vm.DbDailyRebuildTime); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(PlayoutController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs b/ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs new file mode 100644 index 000000000..aa846a305 --- /dev/null +++ b/ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs @@ -0,0 +1,130 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using MediatR; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; +using Unit = LanguageExt.Unit; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Integration; + +[TestFixture] +public class PlayoutLifecycleIntegrationTests +{ + private InMemoryTvContext _db = null!; + private Channel _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task CreateClassic_Should_Link_Channel_And_Schedule_And_Enqueue_Build() + { + (int channelId, int scheduleId) = await SeedChannelAndSchedule(); + var handler = new CreateClassicPlayoutHandler(_worker.Writer, _db.Factory); + + Either result = + await handler.Handle(new CreateClassicPlayout(channelId, scheduleId), CancellationToken.None); + + CreatePlayoutResponse response = result.Match( + Right: r => r, + Left: error => throw new AssertionException(error.Value)); + + await using TvContext context = _db.CreateContext(); + Playout playout = await context.Playouts.SingleAsync(); + playout.Id.ShouldBe(response.PlayoutId); + playout.ChannelId.ShouldBe(channelId); + playout.ProgramScheduleId.ShouldBe(scheduleId); + playout.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic); + + IBackgroundServiceRequest backgroundRequest = await _worker.Reader.ReadAsync(); + backgroundRequest.ShouldBeOfType().PlayoutId.ShouldBe(playout.Id); + } + + [Test] + public async Task Delete_Should_Remove_Playout_Without_Deleting_Channel_Or_Schedule() + { + (int channelId, int scheduleId) = await SeedChannelAndSchedule(); + var createHandler = new CreateClassicPlayoutHandler(_worker.Writer, _db.Factory); + int playoutId = (await createHandler.Handle(new CreateClassicPlayout(channelId, scheduleId), CancellationToken.None)) + .Match(Right: r => r.PlayoutId, Left: error => throw new AssertionException(error.Value)); + var deleteHandler = new DeletePlayoutHandler( + _worker.Writer, + _db.Factory, + new MockFileSystem(), + Substitute.For()); + + Either result = await deleteHandler.Handle(new DeletePlayout(playoutId), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await using TvContext context = _db.CreateContext(); + (await context.Playouts.CountAsync()).ShouldBe(0); + (await context.Channels.CountAsync(c => c.Id == channelId)).ShouldBe(1); + (await context.ProgramSchedules.CountAsync(ps => ps.Id == scheduleId)).ShouldBe(1); + } + + private async Task<(int ChannelId, int ScheduleId)> SeedChannelAndSchedule() + { + await using TvContext context = _db.CreateContext(); + var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid()) + { + Number = "101", + SortNumber = 101, + Name = "Lifecycle", + Group = string.Empty, + Categories = string.Empty, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + Playouts = [], + Artwork = [], + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + IsEnabled = true, + ShowInEpg = true + }; + var schedule = new ProgramSchedule + { + Name = "Lifecycle", + Items = + [ + new ProgramScheduleItemOne + { + Index = 0, + CollectionType = CollectionType.SearchQuery, + SearchQuery = "news", + PlaybackOrder = PlaybackOrder.Shuffle, + GuideMode = GuideMode.Normal, + Watermarks = [], + GraphicsElements = [] + } + ], + Playouts = [], + ProgramScheduleAlternates = [] + }; + context.Channels.Add(channel); + context.ProgramSchedules.Add(schedule); + await context.SaveChangesAsync(); + return (channel.Id, schedule.Id); + } +} diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs new file mode 100644 index 000000000..366257cd0 --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -0,0 +1,77 @@ +using System.ComponentModel.DataAnnotations; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Extensions; +using ErsatzTV.Filters; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] +public class PlayoutController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")] + [Tags("Playouts")] + [EndpointSummary("Get a playout by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetPlayoutById(id), cancellationToken); + return result.Map(ToResponse).ToGetResult(); + } + + [HttpPost("/api/playouts")] + [Tags("Playouts")] + [EndpointSummary("Create a classic playout")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreatePlayoutRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async created => + { + Option playout = + await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken); + return playout.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpDelete("/api/playouts/{id:int}")] + [Tags("Playouts")] + [EndpointSummary("Delete a playout")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeletePlayout(id), cancellationToken); + return result.ToDeletedResult(); + } + + private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => + PlayoutResponseModel.From( + vm.PlayoutId, + vm.ScheduleKind, + vm.ChannelName, + vm.ChannelNumber, + vm.PlayoutMode, + vm.ScheduleName, + vm.ScheduleFile, + vm.DbDailyRebuildTime); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs new file mode 100644 index 000000000..d82dcf03a --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Playouts; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreatePlayoutRequest(int ChannelId, int ProgramScheduleId) +{ + public CreateClassicPlayout ToCommand() => new(ChannelId, ProgramScheduleId); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 4da969587..ba1907e60 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -1190,6 +1190,225 @@ } } }, + "/api/playouts/{id}": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Get a playout by id", + "operationId": "GetPlayoutById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Playouts" + ], + "summary": "Delete a playout", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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": { + "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/ffmpeg/resolution/by-name/{name}": { "get": { "tags": [ @@ -3235,6 +3454,23 @@ } } }, + "CreatePlayoutRequest": { + "required": [ + "channelId", + "programScheduleId" + ], + "type": "object", + "properties": { + "channelId": { + "type": "integer", + "format": "int32" + }, + "programScheduleId": { + "type": "integer", + "format": "int32" + } + } + }, "CreateScheduleRequest": { "required": [ "name", @@ -3956,6 +4192,73 @@ ], "type": "string" }, + "PlayoutResponseModel": { + "required": [ + "id", + "scheduleKind", + "channelName", + "channelNumber", + "playoutMode", + "scheduleName", + "scheduleFile", + "dailyRebuildTime" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "scheduleKind": { + "$ref": "#/components/schemas/PlayoutScheduleKind" + }, + "channelName": { + "type": [ + "null", + "string" + ] + }, + "channelNumber": { + "type": [ + "null", + "string" + ] + }, + "playoutMode": { + "$ref": "#/components/schemas/ChannelPlayoutMode" + }, + "scheduleName": { + "type": [ + "null", + "string" + ] + }, + "scheduleFile": { + "type": [ + "null", + "string" + ] + }, + "dailyRebuildTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, + "PlayoutScheduleKind": { + "enum": [ + "None", + "Classic", + "Block", + "Sequential", + "Scripted", + "ExternalJson" + ], + "type": "string" + }, "ProblemDetails": { "type": "object", "properties": { @@ -5186,6 +5489,9 @@ { "name": "Maintenance" }, + { + "name": "Playouts" + }, { "name": "Resolution" }, -- 2.47.3 From 861827dcbc22ec0ba9c3a5ae936a77d50b23560c Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 30 Jun 2026 07:29:47 +0200 Subject: [PATCH 0004/1118] feat(api): standardize ffmpeg profile endpoints refs #38 --- .../Commands/CreateFFmpegProfileHandler.cs | 27 +- .../Commands/DeleteFFmpegProfileHandler.cs | 27 +- .../Commands/UpdateFFmpegProfileHandler.cs | 38 +- .../FFmpegProfileHandlerTests.cs | 189 +++++++++ .../ApiErrorResponseMetadataTests.cs | 11 + .../CollectionControllerSecurityTests.cs | 4 + .../FFmpegProfileControllerTests.cs | 291 +++++++++++++ .../OpenApiErrorResponseContractTests.cs | 10 + .../Filters/ApiKeyAuthorizationFilterTests.cs | 10 +- .../Api/FFmpegProfileController.cs | 86 +++- .../Controllers/Api/LibrariesController.cs | 2 + .../Controllers/Api/MaintenanceController.cs | 2 + .../Requests/CreateFFmpegProfileRequest.cs | 71 ++++ .../Requests/UpdateFFmpegProfileRequest.cs | 72 ++++ ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs | 7 +- ErsatzTV/wwwroot/openapi/v1.json | 398 +++++++++++++++--- docs/rest-api.md | 13 +- 17 files changed, 1147 insertions(+), 111 deletions(-) create mode 100644 ErsatzTV.Tests/Application/FFmpegProfiles/FFmpegProfileHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateFFmpegProfileRequest.cs diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs index b38e8300f..49df9a199 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -24,8 +25,15 @@ public class CreateFFmpegProfileHandler : CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile)); + Option maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken); + return await maybeResolutionId.Match( + Some: async resolutionId => + { + Validation validation = Validate(request, resolutionId); + return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile)); + }, + None: () => Task.FromResult>( + new NotFoundError($"[Resolution] {request.ResolutionId} does not exist"))); } private async Task PersistFFmpegProfile( @@ -38,13 +46,11 @@ public class CreateFFmpegProfileHandler : return new CreateFFmpegProfileResult(ffmpegProfile.Id); } - private static async Task> Validate( - TvContext dbContext, + private static Validation Validate( CreateFFmpegProfile request, - CancellationToken cancellationToken) => - (ValidateName(request), ValidateThreadCount(request), - await ResolutionMustExist(dbContext, request, cancellationToken)) - .Apply((name, threadCount, resolutionId) => + int resolutionId) => + (ValidateName(request), ValidateThreadCount(request)) + .Apply((name, threadCount) => { var hwAccel = request.NormalizeVideo ? request.HardwareAcceleration @@ -110,12 +116,11 @@ public class CreateFFmpegProfileHandler : private static Validation ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) => createFFmpegProfile.AtLeast(0)(p => p.ThreadCount); - private static Task> ResolutionMustExist( + private static Task> ResolutionMustExist( TvContext dbContext, CreateFFmpegProfile createFFmpegProfile, CancellationToken cancellationToken) => dbContext.Resolutions .SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken) - .MapT(r => r.Id) - .Map(o => o.ToValidation($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist")); + .MapT(r => r.Id); } diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs index a7cff4b38..4a5c91e67 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; @@ -19,8 +20,19 @@ public class DeleteFFmpegProfileHandler( CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(p => DoDeletion(dbContext, p)); + Option maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken); + return await maybeProfile.Match( + Some: async profile => + { + Validation validation = await Validate( + dbContext, + request, + profile, + cancellationToken); + return await validation.Apply(p => DoDeletion(dbContext, p)); + }, + None: () => Task.FromResult>( + new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist"))); } private async Task DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile) @@ -34,19 +46,18 @@ public class DeleteFFmpegProfileHandler( private async Task> Validate( TvContext dbContext, DeleteFFmpegProfile request, + FFmpegProfile profile, CancellationToken cancellationToken) => (await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken), - await FFmpegProfileMustNotBeDefault(request, cancellationToken), - await FFmpegProfileMustExist(dbContext, request, cancellationToken)) - .Apply((_, _, ffmpegProfile) => ffmpegProfile); + await FFmpegProfileMustNotBeDefault(request, cancellationToken)) + .Apply((_, _) => profile); - private static Task> FFmpegProfileMustExist( + private static Task> FFmpegProfileMustExist( TvContext dbContext, DeleteFFmpegProfile request, CancellationToken cancellationToken) => dbContext.FFmpegProfiles - .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken) - .Map(o => o.ToValidation($"FFmpegProfile {request.FFmpegProfileId} does not exist")); + .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken); private static async Task> FFmpegProfileMustNotBeUsed( TvContext dbContext, diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs index 4966d6155..5fc566c48 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.FFmpeg.Preset; @@ -17,8 +18,22 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory dbContextFa CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken)); + Option maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken); + return await maybeProfile.Match( + Some: async profile => + { + Option maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken); + return await maybeResolutionId.Match( + Some: async _ => + { + Validation validation = await Validate(dbContext, request, profile); + return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken)); + }, + None: () => Task.FromResult>( + new NotFoundError($"[Resolution] {request.ResolutionId} does not exist"))); + }, + None: () => Task.FromResult>( + new NotFoundError("FFmpegProfile does not exist."))); } private async Task ApplyUpdateRequest( @@ -109,20 +124,16 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory dbContextFa private static async Task> Validate( TvContext dbContext, UpdateFFmpegProfile request, - CancellationToken cancellationToken) => - (await FFmpegProfileMustExist(dbContext, request, cancellationToken), - await ValidateName(dbContext, request), - ValidateThreadCount(request), - await ResolutionMustExist(dbContext, request, cancellationToken)) - .Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate); + FFmpegProfile profile) => + (await ValidateName(dbContext, request), ValidateThreadCount(request)) + .Apply((_, _) => profile); - private static Task> FFmpegProfileMustExist( + private static Task> FFmpegProfileMustExist( TvContext dbContext, UpdateFFmpegProfile updateFFmpegProfile, CancellationToken cancellationToken) => dbContext.FFmpegProfiles - .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken) - .Map(o => o.ToValidation("FFmpegProfile does not exist.")); + .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken); private static async Task> ValidateName( TvContext dbContext, @@ -147,12 +158,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory dbContextFa private static Validation ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) => updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount); - private static Task> ResolutionMustExist( + private static Task> ResolutionMustExist( TvContext dbContext, UpdateFFmpegProfile updateFFmpegProfile, CancellationToken cancellationToken) => dbContext.Resolutions .SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken) - .MapT(r => r.Id) - .Map(o => o.ToValidation($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist")); + .MapT(r => r.Id); } diff --git a/ErsatzTV.Tests/Application/FFmpegProfiles/FFmpegProfileHandlerTests.cs b/ErsatzTV.Tests/Application/FFmpegProfiles/FFmpegProfileHandlerTests.cs new file mode 100644 index 000000000..b8ee3b71f --- /dev/null +++ b/ErsatzTV.Tests/Application/FFmpegProfiles/FFmpegProfileHandlerTests.cs @@ -0,0 +1,189 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.FFmpegProfiles; + +[TestFixture] +public class FFmpegProfileHandlerTests +{ + private InMemoryTvContext _db = null!; + private IConfigElementRepository _configElementRepository = null!; + private ISearchTargets _searchTargets = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _configElementRepository = Substitute.For(); + _configElementRepository.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.None); + _searchTargets = Substitute.For(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Create_Should_Return_NotFoundError_When_Resolution_Missing() + { + var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); + + Either result = + await handler.Handle(MakeCreate(resolutionId: 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_NotFoundError_When_Profile_Missing() + { + var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); + + Either result = + await handler.Handle(MakeUpdate(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_NotFoundError_When_Resolution_Missing() + { + await SeedProfile(1); + var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); + + Either result = + await handler.Handle(MakeUpdate(1, resolutionId: 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_NotFoundError_When_Profile_Missing() + { + var handler = new DeleteFFmpegProfileHandler(_db.Factory, _configElementRepository, _searchTargets); + + Either result = await handler.Handle(new DeleteFFmpegProfile(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); + + private async Task SeedProfile(int id) + { + await using TvContext context = _db.CreateContext(); + context.FFmpegProfiles.Add(new FFmpegProfile + { + Id = id, + Name = "Default", + ThreadCount = 1, + NormalizeAudio = true, + NormalizeVideo = true, + HardwareAcceleration = HardwareAccelerationKind.None, + VaapiDisplay = "drm", + VaapiDriver = VaapiDriver.Default, + VaapiDevice = "/dev/dri/renderD128", + ResolutionId = 1, + ScalingBehavior = ScalingBehavior.ScaleAndPad, + PadMode = FilterMode.Software, + VideoFormat = FFmpegProfileVideoFormat.H264, + VideoProfile = string.Empty, + VideoPreset = string.Empty, + BitDepth = FFmpegProfileBitDepth.EightBit, + VideoBitrate = 2_000, + VideoBufferSize = 4_000, + TonemapAlgorithm = FFmpegProfileTonemapAlgorithm.Linear, + AudioFormat = FFmpegProfileAudioFormat.Aac, + AudioBitrate = 192, + AudioBufferSize = 384, + NormalizeLoudnessMode = NormalizeLoudnessMode.Off, + AudioChannels = 2, + AudioSampleRate = 48_000, + NormalizeFramerate = false, + NormalizeColors = false, + DeinterlaceVideo = false + }); + await context.SaveChangesAsync(); + } + + private static CreateFFmpegProfile MakeCreate(int resolutionId) => + new( + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + resolutionId, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); + + private static UpdateFFmpegProfile MakeUpdate(int id, int resolutionId = 1) => + new( + id, + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + resolutionId, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); +} diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 14f15bf18..e290ede9c 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -55,6 +55,16 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status401Unauthorized)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)] public void Api_Error_Response_Metadata_Should_Document_ProblemDetails( Type controllerType, string actionName, @@ -69,4 +79,5 @@ public class ApiErrorResponseMetadataTests metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}"); metadata.Type.ShouldBe(typeof(ProblemDetails)); } + } diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs index 3f4776b37..73555eeff 100644 --- a/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs @@ -12,6 +12,8 @@ namespace ErsatzTV.Tests.Controllers; public class CollectionControllerSecurityTests { [TestCase(typeof(CollectionController))] + [TestCase(typeof(LibrariesController))] + [TestCase(typeof(MaintenanceController))] [TestCase(typeof(SmartCollectionController))] public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType) { @@ -23,6 +25,8 @@ public class CollectionControllerSecurityTests } [TestCase(typeof(CollectionController))] + [TestCase(typeof(LibrariesController))] + [TestCase(typeof(MaintenanceController))] [TestCase(typeof(SmartCollectionController))] public void Every_Mutating_Action_Should_Be_Protected(Type controllerType) { diff --git a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs new file mode 100644 index 000000000..6fd2111d2 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs @@ -0,0 +1,291 @@ +using System.Reflection; +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.FFmpegProfiles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Filters; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class FFmpegProfileControllerTests +{ + private FFmpegProfileController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new FFmpegProfileController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles"); + ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles/{id:int}"); + ShouldHaveActionRoute("POST", "/api/ffmpeg/profiles"); + ShouldHaveActionRoute("PUT", "/api/ffmpeg/profiles/{id:int}"); + ShouldHaveActionRoute("DELETE", "/api/ffmpeg/profiles/{id:int}"); + } + + [Test] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter() + { + ServiceFilterAttribute? filter = typeof(FFmpegProfileController) + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull("FFmpegProfileController must carry ApiKeyAuthorizationFilter at the class level"); + } + + [Test] + public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract() + { + ParameterInfo createRequest = typeof(FFmpegProfileController) + .GetMethod(nameof(FFmpegProfileController.AddOne))! + .GetParameters() + .Single(p => p.Name == "request"); + ParameterInfo updateRequest = typeof(FFmpegProfileController) + .GetMethod(nameof(FFmpegProfileController.UpdateOne))! + .GetParameters() + .Single(p => p.Name == "request"); + + createRequest.ParameterType.ShouldBe(typeof(CreateFFmpegProfileRequest)); + updateRequest.ParameterType.ShouldBe(typeof(UpdateFFmpegProfileRequest)); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + FFmpegFullProfileResponseModel vm = MakeVm(4); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + problemDetails.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateFFmpegProfileResult(7))); + FFmpegFullProfileResponseModel vm = MakeVm(7); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/ffmpeg/profiles/7"); + created.Value.ShouldBe(vm); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Create_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new UpdateFFmpegProfileResult(8))); + FFmpegFullProfileResponseModel vm = MakeVm(8); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.UpdateOne(8, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => c.FFmpegProfileId == 8), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.UpdateOne(99, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.DeleteProfileAsync(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.DeleteProfileAsync(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static void ShouldHaveActionRoute(string httpMethod, string route) + { + bool exists = typeof(FFmpegProfileController) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .SelectMany(m => m.GetCustomAttributes(inherit: true)) + .Any(a => a.HttpMethods.Contains(httpMethod) && a.Template == route); + + exists.ShouldBeTrue($"Missing route {httpMethod} {route}"); + } + + private static FFmpegFullProfileResponseModel MakeVm(int id) => + new( + id, + "Default", + 1, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + "HD", + ScalingBehavior.ScaleAndPad, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + 2, + 48_000, + false, + false); + + private static CreateFFmpegProfileRequest MakeCreateRequest() => + new( + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + 1, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); + + private static UpdateFFmpegProfileRequest MakeUpdateRequest() => + new( + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + 1, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); +} diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 83f378b0e..71854b956 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -68,6 +68,16 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/playouts", "post", "422")] [TestCase("/api/playouts/{id}", "delete", "404")] [TestCase("/api/playouts/{id}", "delete", "422")] + [TestCase("/api/ffmpeg/profiles/{id}", "get", "404")] + [TestCase("/api/ffmpeg/profiles", "post", "404")] + [TestCase("/api/ffmpeg/profiles", "post", "401")] + [TestCase("/api/ffmpeg/profiles", "post", "422")] + [TestCase("/api/ffmpeg/profiles/{id}", "put", "404")] + [TestCase("/api/ffmpeg/profiles/{id}", "put", "401")] + [TestCase("/api/ffmpeg/profiles/{id}", "put", "422")] + [TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")] + [TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")] + [TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")] public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( string path, string method, diff --git a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs index 3699e5256..9ef312e11 100644 --- a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs +++ b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs @@ -60,7 +60,10 @@ public class ApiKeyAuthorizationFilterTests { AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null); MakeFilter(configuredKey: "secret").OnAuthorization(context); - context.Result.ShouldBeOfType(); + var result = context.Result.ShouldBeOfType(); + var problemDetails = result.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(StatusCodes.Status401Unauthorized); + problemDetails.Title.ShouldBe("Unauthorized"); } [Test] @@ -68,7 +71,10 @@ public class ApiKeyAuthorizationFilterTests { AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong"); MakeFilter(configuredKey: "secret").OnAuthorization(context); - context.Result.ShouldBeOfType(); + var result = context.Result.ShouldBeOfType(); + var problemDetails = result.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(StatusCodes.Status401Unauthorized); + problemDetails.Title.ShouldBe("Unauthorized"); } [Test] diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs index 2dfda93b8..0e9b733fc 100644 --- a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -1,44 +1,106 @@ using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.FFmpegProfiles; +using ErsatzTV.Extensions; +using ErsatzTV.Filters; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[EndpointGroupName("general")] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class FFmpegProfileController(IMediator mediator) : ControllerBase { - [HttpGet("/api/ffmpeg/profiles", Name="GetFFmpegProfiles")] + [HttpGet("/api/ffmpeg/profiles", Name = "GetFFmpegProfiles")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Get all FFmpeg profiles")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) => await mediator.Send(new GetAllFFmpegProfilesForApi(), cancellationToken); - [HttpPost("/api/ffmpeg/profiles/new", Name="CreateFFmpegProfile")] + [HttpGet("/api/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Get an FFmpeg profile by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = + await mediator.Send(new GetFFmpegFullProfileByIdForApi(id), cancellationToken); + return result.ToGetResult(); + } + + [HttpPost("/api/ffmpeg/profiles", Name = "CreateFFmpegProfile")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Create an FFmpeg profile")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddOne( [Required] [FromBody] - CreateFFmpegProfile request, + CreateFFmpegProfileRequest request, CancellationToken cancellationToken) { - Either result = await mediator.Send(request, cancellationToken); - return result.Match(Ok, error => Problem(error.ToString())); + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async created => + { + Option profile = + await mediator.Send(new GetFFmpegFullProfileByIdForApi(created.FFmpegProfileId), cancellationToken); + return profile.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/ffmpeg/profiles/{vm.Id}", vm), + None: () => ApiResults.NotFoundProblem()); + }); } - [HttpPut("/api/ffmpeg/profiles/update", Name="UpdateFFmpegProfile")] + [HttpPut("/api/ffmpeg/profiles/{id:int}", Name = "UpdateFFmpegProfile")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Update an FFmpeg profile")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task UpdateOne( + int id, [Required] [FromBody] - UpdateFFmpegProfile request, + UpdateFFmpegProfileRequest request, CancellationToken cancellationToken) { - Either result = await mediator.Send(request, cancellationToken); - return result.Match(Ok, error => Problem(error.ToString())); + Either result = + await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async updated => + { + Option profile = + await mediator.Send(new GetFFmpegFullProfileByIdForApi(updated.FFmpegProfileId), cancellationToken); + return profile.Match( + Some: vm => (IActionResult)new OkObjectResult(vm), + None: () => ApiResults.NotFoundProblem()); + }); } - [HttpDelete("/api/ffmpeg/delete/{id:int}", Name="DeleteFFmpegProfile")] + [HttpDelete("/api/ffmpeg/profiles/{id:int}", Name = "DeleteFFmpegProfile")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Delete an FFmpeg profile")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task DeleteProfileAsync(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken); - return result.Match(_ => Ok(), error => Conflict(error.ToString())); + return result.ToDeletedResult(); } } diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index efeadd1ae..ff76280ec 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -1,5 +1,6 @@ using ErsatzTV.Application.Libraries; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) { [HttpPost("/api/libraries/{id:int}/scan")] diff --git a/ErsatzTV/Controllers/Api/MaintenanceController.cs b/ErsatzTV/Controllers/Api/MaintenanceController.cs index b6009e32a..26977cbac 100644 --- a/ErsatzTV/Controllers/Api/MaintenanceController.cs +++ b/ErsatzTV/Controllers/Api/MaintenanceController.cs @@ -2,6 +2,7 @@ using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Maintenance; using ErsatzTV.Core; +using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -9,6 +10,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class MaintenanceController(IMediator mediator, ChannelWriter workerChannel) { [HttpGet("/api/maintenance/gc")] diff --git a/ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs new file mode 100644 index 000000000..078901d67 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs @@ -0,0 +1,71 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateFFmpegProfileRequest( + string Name, + int ThreadCount, + bool NormalizeAudio, + bool NormalizeVideo, + HardwareAccelerationKind HardwareAcceleration, + string VaapiDisplay, + VaapiDriver VaapiDriver, + string VaapiDevice, + int? QsvExtraHardwareFrames, + int ResolutionId, + ScalingBehavior ScalingBehavior, + FilterMode PadMode, + FFmpegProfileVideoFormat VideoFormat, + string VideoProfile, + string VideoPreset, + bool AllowBFrames, + FFmpegProfileBitDepth BitDepth, + int VideoBitrate, + int VideoBufferSize, + FFmpegProfileTonemapAlgorithm TonemapAlgorithm, + FFmpegProfileAudioFormat AudioFormat, + int AudioBitrate, + int AudioBufferSize, + NormalizeLoudnessMode NormalizeLoudnessMode, + double? TargetLoudness, + int AudioChannels, + int AudioSampleRate, + bool NormalizeFramerate, + bool NormalizeColors, + bool DeinterlaceVideo) +{ + public CreateFFmpegProfile ToCommand() => + new( + Name, + ThreadCount, + NormalizeAudio, + NormalizeVideo, + HardwareAcceleration, + VaapiDisplay, + VaapiDriver, + VaapiDevice, + QsvExtraHardwareFrames, + ResolutionId, + ScalingBehavior, + PadMode, + VideoFormat, + VideoProfile, + VideoPreset, + AllowBFrames, + BitDepth, + VideoBitrate, + VideoBufferSize, + TonemapAlgorithm, + AudioFormat, + AudioBitrate, + AudioBufferSize, + NormalizeLoudnessMode, + TargetLoudness, + AudioChannels, + AudioSampleRate, + NormalizeFramerate, + NormalizeColors, + DeinterlaceVideo); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegProfileRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegProfileRequest.cs new file mode 100644 index 000000000..aad06f597 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegProfileRequest.cs @@ -0,0 +1,72 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateFFmpegProfileRequest( + string Name, + int ThreadCount, + bool NormalizeAudio, + bool NormalizeVideo, + HardwareAccelerationKind HardwareAcceleration, + string VaapiDisplay, + VaapiDriver VaapiDriver, + string VaapiDevice, + int? QsvExtraHardwareFrames, + int ResolutionId, + ScalingBehavior ScalingBehavior, + FilterMode PadMode, + FFmpegProfileVideoFormat VideoFormat, + string VideoProfile, + string VideoPreset, + bool AllowBFrames, + FFmpegProfileBitDepth BitDepth, + int VideoBitrate, + int VideoBufferSize, + FFmpegProfileTonemapAlgorithm TonemapAlgorithm, + FFmpegProfileAudioFormat AudioFormat, + int AudioBitrate, + int AudioBufferSize, + NormalizeLoudnessMode NormalizeLoudnessMode, + double? TargetLoudness, + int AudioChannels, + int AudioSampleRate, + bool NormalizeFramerate, + bool NormalizeColors, + bool DeinterlaceVideo) +{ + public UpdateFFmpegProfile ToCommand(int id) => + new( + id, + Name, + ThreadCount, + NormalizeAudio, + NormalizeVideo, + HardwareAcceleration, + VaapiDisplay, + VaapiDriver, + VaapiDevice, + QsvExtraHardwareFrames, + ResolutionId, + ScalingBehavior, + PadMode, + VideoFormat, + VideoProfile, + VideoPreset, + AllowBFrames, + BitDepth, + VideoBitrate, + VideoBufferSize, + TonemapAlgorithm, + AudioFormat, + AudioBitrate, + AudioBufferSize, + NormalizeLoudnessMode, + TargetLoudness, + AudioChannels, + AudioSampleRate, + NormalizeFramerate, + NormalizeColors, + DeinterlaceVideo); +} diff --git a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs index f737b9cbd..e0c33c70e 100644 --- a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs +++ b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs @@ -42,7 +42,12 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided) || !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal)) { - context.Result = new UnauthorizedResult(); + context.Result = new UnauthorizedObjectResult(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = "A valid API key is required for write requests." + }); } } } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index ba1907e60..e52fa2841 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -932,8 +932,9 @@ "/api/ffmpeg/profiles": { "get": { "tags": [ - "FFmpegProfile" + "FFmpeg Profiles" ], + "summary": "Get all FFmpeg profiles", "operationId": "GetFFmpegProfiles", "responses": { "200": { @@ -966,90 +967,129 @@ } } } - } - }, - "/api/ffmpeg/profiles/new": { + }, "post": { "tags": [ - "FFmpegProfile" + "FFmpeg Profiles" ], + "summary": "Create an FFmpeg profile", "operationId": "CreateFFmpegProfile", "requestBody": { "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } }, "application/*+json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } } }, "required": true }, "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/ffmpeg/profiles/update": { - "put": { - "tags": [ - "FFmpegProfile" - ], - "operationId": "UpdateFFmpegProfile", - "requestBody": { - "content": { - "application/json-patch+json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "OK" + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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/ffmpeg/delete/{id}": { - "delete": { + "/api/ffmpeg/profiles/{id}": { + "get": { "tags": [ - "FFmpegProfile" + "FFmpeg Profiles" ], - "operationId": "DeleteFFmpegProfile", + "summary": "Get an FFmpeg profile by id", + "operationId": "GetFFmpegProfileById", "parameters": [ { "name": "id", @@ -1063,7 +1103,252 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "FFmpeg Profiles" + ], + "summary": "Update an FFmpeg profile", + "operationId": "UpdateFFmpegProfile", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "FFmpeg Profiles" + ], + "summary": "Delete an FFmpeg profile", + "operationId": "DeleteFFmpegProfile", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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" + } + } + } } } } @@ -3296,7 +3581,7 @@ } } }, - "CreateFFmpegProfile": { + "CreateFFmpegProfileRequest": { "required": [ "name", "threadCount", @@ -5149,9 +5434,8 @@ } } }, - "UpdateFFmpegProfile": { + "UpdateFFmpegProfileRequest": { "required": [ - "fFmpegProfileId", "name", "threadCount", "normalizeAudio", @@ -5185,10 +5469,6 @@ ], "type": "object", "properties": { - "fFmpegProfileId": { - "type": "integer", - "format": "int32" - }, "name": { "type": [ "null", @@ -5481,7 +5761,7 @@ "name": "Collections" }, { - "name": "FFmpegProfile" + "name": "FFmpeg Profiles" }, { "name": "Libraries" diff --git a/docs/rest-api.md b/docs/rest-api.md index 2644232b1..b6387e325 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -44,8 +44,10 @@ A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Valida - **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`. - `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.** -### 2.5 Two existing CRUD controllers are non-idiomatic → standardize -`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests. +### 2.5 Existing CRUD controllers are standardized +`SmartCollectionController` and `FFmpegProfileController` previously used verb-in-path routes (`/new`, +`/update`, `/delete/{id}`). They have been retrofitted to idiomatic REST routes as part of the REST #2 +slice work, gated by characterization tests and the shared `ProblemDetails` error contract. ### 2.6 Auth today A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**. @@ -116,7 +118,9 @@ New endpoints carry `[EndpointGroupName("general")]` → appear in the existing ## 4. Standardization scope -- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green). +- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green). Completed in #35/#38. +- CORS review in #38: the app still uses the existing global `AllowAll` policy. REST mutations are protected by the optional API-key write filter, and changing CORS defaults would be an operational exposure decision rather than an API-shape cleanup. Keep CORS tightening as backlog if write APIs are exposed beyond the LAN. +- Sweep result in #38: REST #2 CRUD controllers now use idiomatic routes. Older operational endpoints such as `/api/libraries/{id}/scan`, `/api/maintenance/empty_trash`, and `/api/maintenance/clean_artwork` remain outside the REST #2 CRUD standardization scope. - Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code. ## 5. Increment plan (sub-issues under #2 as tracker) @@ -127,7 +131,7 @@ One slice = one branch = one PR. PR runs `test` + `migrations` (both required); - **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first). - **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written. - **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong. -- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d. +- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d. Completed: FFmpeg profile CRUD now uses `/api/ffmpeg/profiles[/{id}]`, request DTOs, API-key write filtering, `ProblemDetails` 404/422 responses, and generated OpenAPI metadata. Deferred: configurable CORS tightening and legacy operational endpoint reshaping. **Sequencing:** #2a first (sets every convention the others copy), then #2b–#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally. @@ -140,6 +144,7 @@ One slice = one branch = one PR. PR runs `test` + `migrations` (both required); ## 7. Open items / backlog seeds - CORS tightening for mutation routes (if exposed beyond LAN). +- Legacy operational `/api` command routes (`libraries/*/scan`, maintenance actions) still use older action-style names. They are outside REST #2 CRUD and should be handled in a separate operational API cleanup if needed. - List-endpoint filtering/sorting/pagination depth (new-UI driven). - API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`). - Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local. -- 2.47.3 From 8e6379a51ba3816cfd8b3bd5c88a6487005e574e Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 30 Jun 2026 18:14:23 +0200 Subject: [PATCH 0005/1118] test: make channel logo generator testable refs #39 --- .../ErsatzTV.Core.Tests.csproj | 5 +++ .../Images/ChannelLogoGeneratorTests.cs | 41 +++++++++++++++++++ ErsatzTV.Core/Images/ChannelLogoGenerator.cs | 28 ++++++++++--- 3 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Images/ChannelLogoGeneratorTests.cs diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index 867f66761..bdc224593 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -32,4 +32,9 @@ + + + + + diff --git a/ErsatzTV.Core.Tests/Images/ChannelLogoGeneratorTests.cs b/ErsatzTV.Core.Tests/Images/ChannelLogoGeneratorTests.cs new file mode 100644 index 000000000..09b6238be --- /dev/null +++ b/ErsatzTV.Core.Tests/Images/ChannelLogoGeneratorTests.cs @@ -0,0 +1,41 @@ +using ErsatzTV.Core.Images; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Shouldly; +using SkiaSharp; + +namespace ErsatzTV.Core.Tests.Images; + +[TestFixture] +public class ChannelLogoGeneratorTests +{ + [TestCase("News", 100, 200)] + [TestCase("A Very Long Channel Name That Should Force Font Shrinking", 100, 200)] + [TestCase("Tall Logo", 180, 120)] + public void GenerateChannelLogo_WithInjectedAssets_ReturnsValidPngWithRequestedDimensions( + string text, + int logoHeight, + int logoWidth) + { + var generator = new ChannelLogoGenerator( + NullLogger.Instance, + TestAssetPath("Resources", "Images", "ersatztv-500.png"), + TestAssetPath("Resources", "Fonts", "Sen.ttf")); + + byte[] bytes = generator.GenerateChannelLogo(text, logoHeight, logoWidth, CancellationToken.None) + .Match( + Left: error => throw new AssertionException(error.Value), + Right: identity); + + using var stream = new MemoryStream(bytes); + using var codec = SKCodec.Create(stream); + + codec.ShouldNotBeNull(); + codec.EncodedFormat.ShouldBe(SKEncodedImageFormat.Png); + codec.Info.Width.ShouldBe(logoWidth); + codec.Info.Height.ShouldBe(logoHeight); + } + + private static string TestAssetPath(params string[] segments) => + Path.Combine([TestContext.CurrentContext.TestDirectory, .. segments]); +} diff --git a/ErsatzTV.Core/Images/ChannelLogoGenerator.cs b/ErsatzTV.Core/Images/ChannelLogoGenerator.cs index 4f6504a90..fae401afa 100644 --- a/ErsatzTV.Core/Images/ChannelLogoGenerator.cs +++ b/ErsatzTV.Core/Images/ChannelLogoGenerator.cs @@ -10,11 +10,25 @@ public class ChannelLogoGenerator : IChannelLogoGenerator public const string GetRoute = "/iptv/logos/gen"; public const string GetRouteQueryParamName = "text"; + private readonly string _fontPath; private readonly ILogger _logger; + private readonly string _overlayImagePath; public ChannelLogoGenerator( - ILogger logger) => + ILogger logger) + : this(logger, DefaultOverlayImagePath(), DefaultFontPath()) + { + } + + public ChannelLogoGenerator( + ILogger logger, + string overlayImagePath, + string fontPath) + { _logger = logger; + _overlayImagePath = overlayImagePath; + _fontPath = fontPath; + } public Either GenerateChannelLogo( string text, @@ -29,13 +43,11 @@ public class ChannelLogoGenerator : IChannelLogoGenerator canvas.Clear(SKColors.Black); //etv logo - string overlayImagePath = Path.Combine("wwwroot", "images", "ersatztv-500.png"); - using var overlayImage = SKBitmap.Decode(overlayImagePath); + using var overlayImage = SKBitmap.Decode(_overlayImagePath); canvas.DrawBitmap(overlayImage, new SKRect(155, 60, 205, 110)); //Custom Font - string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf"); - using var fontTypeface = SKTypeface.FromFile(fontPath); + using var fontTypeface = SKTypeface.FromFile(_fontPath); var fontSize = 30; var font = new SKFont { @@ -80,4 +92,10 @@ public class ChannelLogoGenerator : IChannelLogoGenerator public static string GenerateChannelLogoUrl(Channel channel) => $"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}"; + + private static string DefaultFontPath() => + Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf"); + + private static string DefaultOverlayImagePath() => + Path.Combine("wwwroot", "images", "ersatztv-500.png"); } -- 2.47.3 From 527332a3acdc16452b24fb89da11656cd342c61f Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 30 Jun 2026 18:49:35 +0200 Subject: [PATCH 0006/1118] feat(api): default-deny mutating API writes Refs #43 --- .../Controllers/ApiControllerSecurityTests.cs | 122 ++++++++++++++++++ .../ChannelControllerSecurityTests.cs | 62 --------- .../CollectionControllerSecurityTests.cs | 60 --------- .../FFmpegProfileControllerTests.cs | 11 -- .../Controllers/PlayoutControllerTests.cs | 11 -- .../Controllers/ScheduleControllerTests.cs | 11 -- .../SmartCollectionControllerTests.cs | 11 -- .../Filters/ApiKeyAuthorizationFilterTests.cs | 36 +++++- ErsatzTV/Controllers/Api/ChannelController.cs | 6 - .../Controllers/Api/CollectionController.cs | 2 - .../Api/FFmpegProfileController.cs | 2 - .../Controllers/Api/LibrariesController.cs | 2 - .../Controllers/Api/MaintenanceController.cs | 2 - ErsatzTV/Controllers/Api/PlayoutController.cs | 2 - ErsatzTV/Controllers/Api/ScannerController.cs | 2 + .../Controllers/Api/ScheduleController.cs | 2 - .../Api/SmartCollectionController.cs | 2 - ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs | 33 ++++- .../SkipApiKeyAuthorizationAttribute.cs | 9 ++ ErsatzTV/Startup.cs | 1 + docs/rest-api.md | 10 +- 21 files changed, 205 insertions(+), 194 deletions(-) create mode 100644 ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs delete mode 100644 ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs delete mode 100644 ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs create mode 100644 ErsatzTV/Filters/SkipApiKeyAuthorizationAttribute.cs diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs new file mode 100644 index 000000000..4f00c1279 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using ErsatzTV; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Filters; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class ApiControllerSecurityTests +{ + private static readonly bool ApiKeyAuthorizationFilterIsGlobal = IsApiKeyAuthorizationFilterRegisteredGlobally(); + + [Test] + public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt() + { + Type[] apiControllers = + [ + typeof(ChannelController), + typeof(CollectionController), + typeof(FFmpegProfileController), + typeof(LibrariesController), + typeof(MaintenanceController), + typeof(PlayoutController), + typeof(ScannerController), + typeof(ScheduleController), + typeof(ScriptedScheduleController), + typeof(SessionController), + typeof(SmartCollectionController) + ]; + + foreach (Type controllerType in apiControllers) + { + bool controllerSkipsApiKey = controllerType + .GetCustomAttributes(inherit: true) + .Any(); + + foreach (MethodInfo action in controllerType + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + bool isMutating = action + .GetCustomAttributes(inherit: true) + .SelectMany(a => a.HttpMethods) + .Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE"); + + if (!isMutating) + { + continue; + } + + bool actionSkipsApiKey = action + .GetCustomAttributes(inherit: true) + .Any(); + + (controllerSkipsApiKey || actionSkipsApiKey || IsGloballyProtected()) + .ShouldBeTrue($"{controllerType.Name}.{action.Name} must be covered by global API write auth or explicitly exempt"); + } + } + } + + [Test] + public void ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller() + { + Type[] exemptControllers = typeof(ScannerController) + .Assembly + .GetTypes() + .Where(t => t.Namespace == typeof(ScannerController).Namespace) + .Where(t => t.GetCustomAttributes(inherit: true).Any()) + .Where(t => t.GetCustomAttributes(inherit: true).Any()) + .ToArray(); + + exemptControllers.ShouldBe([typeof(ScannerController)]); + } + + [Test] + public void Startup_Should_Register_ApiKeyAuthorizationFilter_Globally() + { + ApiKeyAuthorizationFilterIsGlobal.ShouldBeTrue(); + } + + private static bool IsGloballyProtected() => ApiKeyAuthorizationFilterIsGlobal; + + private static bool IsApiKeyAuthorizationFilterRegisteredGlobally() + { + var settings = new Dictionary + { + ["provider"] = "sqlite", + ["ConnectionStrings:Data"] = "Data Source=:memory:" + }; + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + + var environment = Substitute.For(); + environment.ApplicationName.Returns("ErsatzTV"); + environment.EnvironmentName.Returns("Development"); + environment.ContentRootPath.Returns(TestContext.CurrentContext.TestDirectory); + environment.WebRootPath.Returns(TestContext.CurrentContext.TestDirectory); + environment.ContentRootFileProvider.Returns(new NullFileProvider()); + environment.WebRootFileProvider.Returns(new NullFileProvider()); + + var services = new ServiceCollection(); + new Startup(configuration, environment).ConfigureServices(services); + + using ServiceProvider provider = services.BuildServiceProvider(); + MvcOptions options = provider.GetRequiredService>().Value; + + return options.Filters + .OfType() + .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + } +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs deleted file mode 100644 index 444051220..000000000 --- a/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Linq; -using System.Reflection; -using ErsatzTV.Controllers.Api; -using ErsatzTV.Filters; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Routing; -using NUnit.Framework; -using Shouldly; - -namespace ErsatzTV.Tests.Controllers; - -/// -/// Guards the API-key write-path contract for : every mutating -/// action (POST/PUT/PATCH/DELETE) must be covered by . -/// The filter is applied at the controller level, so this also protects any future write endpoint -/// added to the controller (regression net for the ResetPlayout bypass). -/// -[TestFixture] -public class ChannelControllerSecurityTests -{ - [Test] - public void Controller_Should_Apply_ApiKeyAuthorizationFilter() - { - ServiceFilterAttribute? filter = typeof(ChannelController) - .GetCustomAttributes(inherit: true) - .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - filter.ShouldNotBeNull("ChannelController must carry the ApiKeyAuthorizationFilter at the class level"); - } - - [Test] - public void Every_Mutating_Action_Should_Be_Protected() - { - MethodInfo[] actions = typeof(ChannelController) - .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - - bool controllerHasFilter = typeof(ChannelController) - .GetCustomAttributes(inherit: true) - .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - foreach (MethodInfo action in actions) - { - bool isMutating = action - .GetCustomAttributes(inherit: true) - .SelectMany(a => a.HttpMethods) - .Any(m => - m is "POST" or "PUT" or "PATCH" or "DELETE"); - - if (!isMutating) - { - continue; - } - - bool actionHasFilter = action - .GetCustomAttributes(inherit: true) - .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - (controllerHasFilter || actionHasFilter) - .ShouldBeTrue($"Mutating action {action.Name} is not protected by ApiKeyAuthorizationFilter"); - } - } -} diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs deleted file mode 100644 index 73555eeff..000000000 --- a/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Reflection; -using ErsatzTV.Controllers.Api; -using ErsatzTV.Filters; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Routing; -using NUnit.Framework; -using Shouldly; - -namespace ErsatzTV.Tests.Controllers; - -[TestFixture] -public class CollectionControllerSecurityTests -{ - [TestCase(typeof(CollectionController))] - [TestCase(typeof(LibrariesController))] - [TestCase(typeof(MaintenanceController))] - [TestCase(typeof(SmartCollectionController))] - public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType) - { - ServiceFilterAttribute? filter = controllerType - .GetCustomAttributes(inherit: true) - .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - filter.ShouldNotBeNull($"{controllerType.Name} must carry ApiKeyAuthorizationFilter at the class level"); - } - - [TestCase(typeof(CollectionController))] - [TestCase(typeof(LibrariesController))] - [TestCase(typeof(MaintenanceController))] - [TestCase(typeof(SmartCollectionController))] - public void Every_Mutating_Action_Should_Be_Protected(Type controllerType) - { - MethodInfo[] actions = controllerType - .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - - bool controllerHasFilter = controllerType - .GetCustomAttributes(inherit: true) - .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - foreach (MethodInfo action in actions) - { - bool isMutating = action - .GetCustomAttributes(inherit: true) - .SelectMany(a => a.HttpMethods) - .Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE"); - - if (!isMutating) - { - continue; - } - - bool actionHasFilter = action - .GetCustomAttributes(inherit: true) - .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - (controllerHasFilter || actionHasFilter) - .ShouldBeTrue($"Mutating action {controllerType.Name}.{action.Name} is not protected"); - } - } -} diff --git a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs index 6fd2111d2..657815b91 100644 --- a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs @@ -7,7 +7,6 @@ using ErsatzTV.Core.Api.FFmpegProfiles; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; -using ErsatzTV.Filters; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -43,16 +42,6 @@ public class FFmpegProfileControllerTests ShouldHaveActionRoute("DELETE", "/api/ffmpeg/profiles/{id:int}"); } - [Test] - public void Controller_Should_Apply_ApiKeyAuthorizationFilter() - { - ServiceFilterAttribute? filter = typeof(FFmpegProfileController) - .GetCustomAttributes(inherit: true) - .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - filter.ShouldNotBeNull("FFmpegProfileController must carry ApiKeyAuthorizationFilter at the class level"); - } - [Test] public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract() { diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 7125d03dd..0726e9a6f 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -6,7 +6,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; -using ErsatzTV.Filters; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -40,16 +39,6 @@ public class PlayoutControllerTests ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}"); } - [Test] - public void Controller_Should_Apply_ApiKeyAuthorizationFilter() - { - ServiceFilterAttribute? filter = typeof(PlayoutController) - .GetCustomAttributes(inherit: true) - .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - filter.ShouldNotBeNull("PlayoutController must carry ApiKeyAuthorizationFilter at the class level"); - } - [Test] public void Create_Should_Use_Stable_Request_Dto() { diff --git a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs index 4c0c11d36..151674a4d 100644 --- a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs @@ -6,7 +6,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; -using ErsatzTV.Filters; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -49,16 +48,6 @@ public class ScheduleControllerTests "/api/schedules/{id:int}/items/{itemId:int}"); } - [Test] - public void Controller_Should_Apply_ApiKeyAuthorizationFilter() - { - ServiceFilterAttribute? filter = typeof(ScheduleController) - .GetCustomAttributes(inherit: true) - .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - filter.ShouldNotBeNull("ScheduleController must carry ApiKeyAuthorizationFilter at the class level"); - } - [Test] public void Update_Should_Use_Stable_Update_Request_Dto() { diff --git a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs index 85a887861..860f4da88 100644 --- a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs @@ -5,7 +5,6 @@ using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.SmartCollections; using ErsatzTV.Core.Errors; -using ErsatzTV.Filters; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -41,16 +40,6 @@ public class SmartCollectionControllerTests ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}"); } - [Test] - public void Controller_Should_Apply_ApiKeyAuthorizationFilter() - { - ServiceFilterAttribute? filter = typeof(SmartCollectionController) - .GetCustomAttributes(inherit: true) - .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); - - filter.ShouldNotBeNull("SmartCollectionController must carry ApiKeyAuthorizationFilter at the class level"); - } - [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { diff --git a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs index 9ef312e11..3e04cd1fe 100644 --- a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs +++ b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs @@ -14,16 +14,27 @@ namespace ErsatzTV.Tests.Filters; [TestFixture] public class ApiKeyAuthorizationFilterTests { - private static AuthorizationFilterContext MakeContext(string method, string? apiKeyHeader) + private static AuthorizationFilterContext MakeContext( + string method, + string? apiKeyHeader, + string path = "/api/channels", + bool skipApiKeyAuthorization = false) { var httpContext = new DefaultHttpContext(); httpContext.Request.Method = method; + httpContext.Request.Path = path; if (apiKeyHeader is not null) { httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader; } - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + var actionDescriptor = new ActionDescriptor(); + if (skipApiKeyAuthorization) + { + actionDescriptor.EndpointMetadata = [new SkipApiKeyAuthorizationAttribute()]; + } + + var actionContext = new ActionContext(httpContext, new RouteData(), actionDescriptor); return new AuthorizationFilterContext(actionContext, new List()); } @@ -92,4 +103,25 @@ public class ApiKeyAuthorizationFilterTests MakeFilter(configuredKey: "secret").OnAuthorization(context); context.Result.ShouldBeNull(); } + + [Test] + public void Should_Allow_Non_Api_Mutating_Request_Even_When_Key_Configured() + { + AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null, path: "/iptv/channels.m3u"); + MakeFilter(configuredKey: "secret").OnAuthorization(context); + context.Result.ShouldBeNull(); + } + + [Test] + public void Should_Allow_Api_Mutating_Request_When_Endpoint_Skips_Api_Key_Authorization() + { + AuthorizationFilterContext context = MakeContext( + "POST", + apiKeyHeader: null, + path: "/api/scan/0f8fad5b-d9cb-469f-a165-70867728950e/progress", + skipApiKeyAuthorization: true); + + MakeFilter(configuredKey: "secret").OnAuthorization(context); + context.Result.ShouldBeNull(); + } } diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index c31682c83..6220adfa5 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -8,7 +8,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Scheduling; using ErsatzTV.Extensions; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -16,11 +15,6 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -// Apply the optional API-key control at the controller level so that EVERY mutating action -// (including future ones) is covered by default; the filter no-ops on read methods (GET) and -// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer -// adding a new write endpoint here cannot accidentally leave it unauthenticated. -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class ChannelController(ChannelWriter workerChannel, IMediator mediator) { [HttpGet("/api/channels")] diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs index b4e1871be..90c3fff48 100644 --- a/ErsatzTV/Controllers/Api/CollectionController.cs +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -3,7 +3,6 @@ using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Extensions; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -11,7 +10,6 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class CollectionController(IMediator mediator) : ControllerBase { [HttpGet("/api/collections")] diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs index 0e9b733fc..386967021 100644 --- a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -4,7 +4,6 @@ using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.FFmpegProfiles; using ErsatzTV.Extensions; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class FFmpegProfileController(IMediator mediator) : ControllerBase { [HttpGet("/api/ffmpeg/profiles", Name = "GetFFmpegProfiles")] diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index ff76280ec..efeadd1ae 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -1,6 +1,5 @@ using ErsatzTV.Application.Libraries; using ErsatzTV.Core.Interfaces.Repositories; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -8,7 +7,6 @@ namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) { [HttpPost("/api/libraries/{id:int}/scan")] diff --git a/ErsatzTV/Controllers/Api/MaintenanceController.cs b/ErsatzTV/Controllers/Api/MaintenanceController.cs index 26977cbac..b6009e32a 100644 --- a/ErsatzTV/Controllers/Api/MaintenanceController.cs +++ b/ErsatzTV/Controllers/Api/MaintenanceController.cs @@ -2,7 +2,6 @@ using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Maintenance; using ErsatzTV.Core; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -10,7 +9,6 @@ namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class MaintenanceController(IMediator mediator, ChannelWriter workerChannel) { [HttpGet("/api/maintenance/gc")] diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 366257cd0..833ce778b 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -4,7 +4,6 @@ using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; using ErsatzTV.Extensions; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class PlayoutController(IMediator mediator) : ControllerBase { [HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")] diff --git a/ErsatzTV/Controllers/Api/ScannerController.cs b/ErsatzTV/Controllers/Api/ScannerController.cs index 8c8404160..fb337cc21 100644 --- a/ErsatzTV/Controllers/Api/ScannerController.cs +++ b/ErsatzTV/Controllers/Api/ScannerController.cs @@ -2,12 +2,14 @@ using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Search; using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Filters; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] [ApiExplorerSettings(IgnoreApi = true)] +[SkipApiKeyAuthorization] [Route("api/scan/{scanId:guid}")] public class ScannerController( IScannerProxyService scannerProxyService, diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs index b66f2f3ce..7592d3562 100644 --- a/ErsatzTV/Controllers/Api/ScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -3,7 +3,6 @@ using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Extensions; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -11,7 +10,6 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class ScheduleController(IMediator mediator) : ControllerBase { [HttpGet("/api/schedules")] diff --git a/ErsatzTV/Controllers/Api/SmartCollectionController.cs b/ErsatzTV/Controllers/Api/SmartCollectionController.cs index 794873a33..4837a4277 100644 --- a/ErsatzTV/Controllers/Api/SmartCollectionController.cs +++ b/ErsatzTV/Controllers/Api/SmartCollectionController.cs @@ -4,7 +4,6 @@ using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.SmartCollections; using ErsatzTV.Extensions; -using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class SmartCollectionController(IMediator mediator) : ControllerBase { [HttpGet("/api/smart-collections")] diff --git a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs index e0c33c70e..8c0643d5c 100644 --- a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs +++ b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs @@ -6,12 +6,12 @@ using Microsoft.Extensions.Primitives; namespace ErsatzTV.Filters; /// -/// Optional API-key authorization for mutating JSON API endpoints (slice #2a). +/// Optional API-key authorization for mutating JSON API endpoints. /// Reads the configured key from Api:WriteKey. When that key is empty the filter is /// a no-op (preserving the current open LAN behavior); when it is set, mutating requests -/// (POST/PUT/PATCH/DELETE) must present a matching X-Api-Key header or receive 401. -/// This is fully independent of and only applies to the actions it -/// decorates — it never affects /iptv/* or any read endpoint. +/// under /api/* (POST/PUT/PATCH/DELETE) must present a matching X-Api-Key +/// header or receive 401. This is fully independent of and never +/// affects /iptv/* or any read endpoint. /// public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter { @@ -20,6 +20,11 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz public void OnAuthorization(AuthorizationFilterContext context) { + if (ShouldSkipApiKeyAuthorization(context)) + { + return; + } + string configuredKey = configuration[ConfigurationKey]; // empty key => API-key auth disabled, endpoint is open @@ -50,4 +55,24 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz }); } } + + private static bool ShouldSkipApiKeyAuthorization(AuthorizationFilterContext context) + { + if (context.Filters.OfType().Any() + || context.ActionDescriptor.EndpointMetadata.OfType().Any()) + { + return true; + } + + if (!context.HttpContext.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string method = context.HttpContext.Request.Method; + return !HttpMethods.IsPost(method) + && !HttpMethods.IsPut(method) + && !HttpMethods.IsPatch(method) + && !HttpMethods.IsDelete(method); + } } diff --git a/ErsatzTV/Filters/SkipApiKeyAuthorizationAttribute.cs b/ErsatzTV/Filters/SkipApiKeyAuthorizationAttribute.cs new file mode 100644 index 000000000..4a1f7e7ea --- /dev/null +++ b/ErsatzTV/Filters/SkipApiKeyAuthorizationAttribute.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace ErsatzTV.Filters; + +/// +/// Marks an internal API endpoint as exempt from global API-key write authorization. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class SkipApiKeyAuthorizationAttribute : Attribute, IFilterMetadata; diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 0f350fc88..0e333f292 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -338,6 +338,7 @@ public class Startup options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter()); options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter()); options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter()); + options.Filters.AddService(); }) .AddNewtonsoftJson(opt => { diff --git a/docs/rest-api.md b/docs/rest-api.md index b6387e325..972340ff8 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -100,11 +100,17 @@ The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapp Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers. ### 3.5 Auth — dedicated API key for writes (decoupled from IPTV) -**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open. +**Mechanism:** a dedicated **API key** for mutations — `Api:WriteKey` / `Api__WriteKey`, +checked against the `X-Api-Key` request header by a global MVC filter. When the key is configured, +all mutating `/api/*` requests (`POST`/`PUT`/`PATCH`/`DELETE`) require the header; when the key is +unset, the LAN-open default is preserved. Reads and all `/iptv/*` routes stay open. The scanner +callback controller is the designed exemption because scanner child processes call +`/api/scan/{scanId}/...` without `X-Api-Key`; any future exemption must be explicit via +`[SkipApiKeyAuthorization]`. **Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled` ← `JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr. -**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern. +**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one global filter + one config key); a standard machine-API pattern. **Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN. -- 2.47.3 From 5560790bcb034245a62cb12931cc0aca59f25390 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 30 Jun 2026 19:33:06 +0200 Subject: [PATCH 0007/1118] feat(jellyfin): sync music video libraries refs #42 --- .../Interfaces/Jellyfin/IJellyfinApiClient.cs | 5 + .../IJellyfinMusicVideoLibraryScanner.cs | 13 ++ .../Jellyfin/JellyfinApiClientTests.cs | 73 ++++++ .../Jellyfin/IJellyfinApi.cs | 20 ++ .../Jellyfin/JellyfinApiClient.cs | 161 ++++++++++++- .../Models/JellyfinLibraryItemResponse.cs | 1 + ...chronizeJellyfinLibraryByIdHandlerTests.cs | 99 ++++++++ .../SynchronizeJellyfinLibraryByIdHandler.cs | 9 + .../JellyfinMusicVideoLibraryScanner.cs | 216 ++++++++++++++++++ ErsatzTV.Scanner/Program.cs | 1 + 10 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMusicVideoLibraryScanner.cs create mode 100644 ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs create mode 100644 ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs create mode 100644 ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs index 1b23a3b3f..e3bf8080f 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs @@ -13,6 +13,11 @@ public interface IJellyfinApiClient string authorizationHeader, JellyfinLibrary library); + IAsyncEnumerable> GetMusicVideoLibraryItems( + string address, + string authorizationHeader, + JellyfinLibrary library); + IAsyncEnumerable> GetShowLibraryItemsWithoutPeople( string address, string authorizationHeader, diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMusicVideoLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMusicVideoLibraryScanner.cs new file mode 100644 index 000000000..5a0019eb1 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMusicVideoLibraryScanner.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Jellyfin; + +namespace ErsatzTV.Core.Interfaces.Jellyfin; + +public interface IJellyfinMusicVideoLibraryScanner +{ + Task> ScanLibrary( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + bool deepScan, + CancellationToken cancellationToken); +} diff --git a/ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs b/ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs new file mode 100644 index 000000000..a369015da --- /dev/null +++ b/ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Infrastructure.Jellyfin; +using LanguageExt; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Infrastructure.Tests.Jellyfin; + +public class JellyfinApiClientTests +{ + [TestFixture] + public class GetLibraries + { + [Test] + public async Task Should_Project_MusicVideo_Libraries() + { + const string response = """ + [ + { + "Name": "Concerts", + "CollectionType": "musicvideos", + "ItemId": "library-1", + "LibraryOptions": { + "PathInfos": [] + } + } + ] + """; + + var client = new JellyfinApiClient( + new MemoryCache(new MemoryCacheOptions()), + Substitute.For(), + Substitute.For(), + new SingleResponseHttpClientFactory(response), + Substitute.For>()); + + Either> result = + await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc"); + + result.IsRight.ShouldBeTrue(); + List libraries = result.RightToSeq().Single(); + libraries.Count.ShouldBe(1); + libraries[0].Name.ShouldBe("Concerts"); + libraries[0].ItemId.ShouldBe("library-1"); + libraries[0].MediaKind.ShouldBe(LibraryMediaKind.MusicVideos); + libraries[0].ShouldSyncItems.ShouldBeFalse(); + libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1"); + } + } + + private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(new SingleResponseHttpMessageHandler(response)); + } + + private sealed class SingleResponseHttpMessageHandler(string response) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(response) + }); + } +} diff --git a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs index 1a895271f..ccb5f56f9 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs @@ -42,6 +42,26 @@ public interface IJellyfinApi [Query] int limit = 0); + [Get("/Items?sortOrder=Ascending&sortBy=SortName")] + Task GetMusicVideoLibraryItems( + [Header("Authorization")] + string authorizationHeader, + [Query] + string parentId, + [Query] + string fields = + "Path,Genres,Artists,Tags,DateCreated,Etag,Overview,Studios,People,OfficialRating,ProviderIds,Chapters", + [Query] + string includeItemTypes = "MusicVideo", + [Query] + bool recursive = true, + [Query] + string filters = "IsNotFolder", + [Query] + int startIndex = 0, + [Query] + int limit = 0); + [Get("/Items?sortOrder=Ascending&sortBy=SortName")] Task GetShowLibraryItemsWithoutPeople( [Header("Authorization")] diff --git a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs index a1a8a6118..5df85c91d 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs @@ -93,6 +93,23 @@ public class JellyfinApiClient : IJellyfinApiClient limit: pageSize), (maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMovie(lib, item)).Flatten()); + public IAsyncEnumerable> GetMusicVideoLibraryItems( + string address, + string authorizationHeader, + JellyfinLibrary library) => + GetPagedLibraryItems( + "JF Music Videos", + address, + library, + library.MediaSourceId, + library.ItemId, + (service, itemId, skip, pageSize) => service.GetMusicVideoLibraryItems( + authorizationHeader, + itemId, + startIndex: skip, + limit: pageSize), + (maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMusicVideo(lib, item)).Flatten()); + public IAsyncEnumerable> GetShowLibraryItemsWithoutPeople( string address, string authorizationHeader, @@ -438,6 +455,15 @@ public class JellyfinApiClient : IJellyfinApiClient Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } }, PathInfos = GetPathInfos(response) }, + "musicvideos" => new JellyfinLibrary + { + ItemId = response.ItemId, + Name = response.Name, + MediaKind = LibraryMediaKind.MusicVideos, + ShouldSyncItems = false, + Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } }, + PathInfos = GetPathInfos(response) + }, // TODO: ??? for music libraries "boxsets" => CacheCollectionLibraryId(response.ItemId), _ => None @@ -484,7 +510,7 @@ public class JellyfinApiClient : IJellyfinApiClient } string path = item.Path ?? string.Empty; - foreach (JellyfinPathInfo pathInfo in library.PathInfos.Filter(pi => + foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi => !string.IsNullOrWhiteSpace(pi.NetworkPath))) { if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal)) @@ -620,6 +646,139 @@ public class JellyfinApiClient : IJellyfinApiClient return metadata; } + private Option ProjectToMusicVideo(JellyfinLibrary library, JellyfinLibraryItemResponse item) + { + try + { + if (item.LocationType != "FileSystem") + { + return None; + } + + if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm") + { + _logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path); + return None; + } + + string path = item.Path ?? string.Empty; + foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi => + !string.IsNullOrWhiteSpace(pi.NetworkPath))) + { + if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal)) + { + path = _jellyfinPathReplacementService.ReplaceNetworkPath( + (JellyfinMediaSource)library.MediaSource, + path, + pathInfo.NetworkPath, + pathInfo.Path); + } + } + + var duration = TimeSpan.FromTicks(item.RunTimeTicks); + var version = new MediaVersion + { + Name = "Main", + Duration = duration, + DateAdded = item.DateCreated.UtcDateTime, + MediaFiles = + [ + new MediaFile + { + Path = path, + PathHash = PathUtils.GetPathHash(path) + } + ], + Streams = [], + Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration) + }; + + MusicVideoMetadata metadata = ProjectToMusicVideoMetadata(item); + + var musicVideo = new MusicVideo + { + MediaVersions = [version], + MusicVideoMetadata = [metadata], + TraktListItems = [] + }; + + return musicVideo; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error projecting Jellyfin music video"); + return None; + } + } + + private static MusicVideoMetadata ProjectToMusicVideoMetadata(JellyfinLibraryItemResponse item) + { + DateTime dateAdded = item.DateCreated.UtcDateTime; + var metadata = new MusicVideoMetadata + { + MetadataKind = MetadataKind.External, + Title = item.Name, + SortTitle = SortTitle.GetSortTitle(item.Name), + Plot = item.Overview, + Year = item.ProductionYear, + DateAdded = dateAdded, + Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), + Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), + Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), + Artists = MusicVideoArtists(item) + .ToList(), + Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(), + Artwork = new List(), + Guids = GuidsFromProviderIds(item.ProviderIds), + Subtitles = new List() + }; + + if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) + { + metadata.ReleaseDate = releaseDate; + } + + if (!string.IsNullOrWhiteSpace(item.ImageTags?.Primary)) + { + var poster = new Artwork + { + ArtworkKind = ArtworkKind.Poster, + Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(poster); + } + + if (item.BackdropImageTags?.Count > 0) + { + var fanArt = new Artwork + { + ArtworkKind = ArtworkKind.FanArt, + Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(fanArt); + } + + return metadata; + } + + private static IEnumerable MusicVideoArtists(JellyfinLibraryItemResponse item) + { + List artists = Optional(item.Artists).Flatten().ToList(); + if (artists.Count == 0) + { + artists = Optional(item.People).Flatten() + .Filter(p => p.Type is "Artist") + .Map(p => p.Name) + .ToList(); + } + + return artists.Filter(name => !string.IsNullOrWhiteSpace(name)) + .Distinct() + .Map(name => new MusicVideoArtist { Name = name }); + } + private static Option ProjectToActor(JellyfinPersonResponse person, DateTime dateAdded) { if (person.Type?.ToLowerInvariant() != "actor") diff --git a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs index e1725c7a1..7babac1eb 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs @@ -10,6 +10,7 @@ public class JellyfinLibraryItemResponse public DateTimeOffset DateCreated { get; set; } public long RunTimeTicks { get; set; } public List Genres { get; set; } + public List Artists { get; set; } public List Tags { get; set; } public int ProductionYear { get; set; } public JellyfinProviderIdsResponse ProviderIds { get; set; } diff --git a/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs b/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs new file mode 100644 index 000000000..79163189e --- /dev/null +++ b/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs @@ -0,0 +1,99 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Scanner.Application.Jellyfin; +using ErsatzTV.Scanner.Core.Interfaces; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Scanner.Tests.Application.Jellyfin; + +public class SynchronizeJellyfinLibraryByIdHandlerTests +{ + [TestFixture] + public class Handle + { + [Test] + public async Task Should_Scan_MusicVideo_Libraries() + { + var scannerProxy = Substitute.For(); + var mediaSourceRepository = Substitute.For(); + var jellyfinSecretStore = Substitute.For(); + var jellyfinMovieLibraryScanner = Substitute.For(); + var jellyfinTelevisionLibraryScanner = Substitute.For(); + var jellyfinMusicVideoLibraryScanner = Substitute.For(); + var libraryRepository = Substitute.For(); + var configElementRepository = Substitute.For(); + + var library = new JellyfinLibrary + { + Id = 42, + Name = "Concerts", + MediaKind = LibraryMediaKind.MusicVideos, + MediaSourceId = 7 + }; + var mediaSource = new JellyfinMediaSource + { + Id = 7, + Connections = + [ + new JellyfinConnection + { + Address = "http://jellyfin.example", + JellyfinMediaSourceId = 7 + } + ] + }; + + mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask()); + mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask()); + jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets + { + Address = "http://jellyfin.example", + ApiKey = "abc" + }); + configElementRepository.GetValue( + Arg.Is(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key), + Arg.Any()) + .Returns(Task.FromResult>(Some(0))); + jellyfinMusicVideoLibraryScanner.ScanLibrary( + Arg.Any(), + library, + true, + Arg.Any()) + .Returns(Right(Unit.Default).AsTask()); + + var handler = new SynchronizeJellyfinLibraryByIdHandler( + scannerProxy, + mediaSourceRepository, + jellyfinSecretStore, + jellyfinMovieLibraryScanner, + jellyfinTelevisionLibraryScanner, + jellyfinMusicVideoLibraryScanner, + libraryRepository, + configElementRepository, + Substitute.For>()); + + Either result = await handler.Handle( + new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true), + CancellationToken.None); + + result.LeftToSeq().ShouldBeEmpty(); + result.IsRight.ShouldBeTrue(); + result.RightToSeq().Single().ShouldBe("Concerts"); + await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary( + Arg.Is(cp => + cp.Address == "http://jellyfin.example" && + cp.ApiKey == "abc" && + cp.MediaSourceId == 7), + library, + true, + Arg.Any()); + await libraryRepository.Received(1).UpdateLastScan(library); + } + } +} diff --git a/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs index 1bcc2abc2..6e14a2b69 100644 --- a/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs +++ b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs @@ -14,6 +14,7 @@ public class private readonly IConfigElementRepository _configElementRepository; private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner; + private readonly IJellyfinMusicVideoLibraryScanner _jellyfinMusicVideoLibraryScanner; private readonly IJellyfinSecretStore _jellyfinSecretStore; private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner; @@ -28,6 +29,7 @@ public class IJellyfinSecretStore jellyfinSecretStore, IJellyfinMovieLibraryScanner jellyfinMovieLibraryScanner, IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner, + IJellyfinMusicVideoLibraryScanner jellyfinMusicVideoLibraryScanner, ILibraryRepository libraryRepository, IConfigElementRepository configElementRepository, ILogger logger) @@ -37,6 +39,7 @@ public class _jellyfinSecretStore = jellyfinSecretStore; _jellyfinMovieLibraryScanner = jellyfinMovieLibraryScanner; _jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner; + _jellyfinMusicVideoLibraryScanner = jellyfinMusicVideoLibraryScanner; _libraryRepository = libraryRepository; _configElementRepository = configElementRepository; _logger = logger; @@ -75,6 +78,12 @@ public class parameters.Library, parameters.DeepScan, cancellationToken), + LibraryMediaKind.MusicVideos => + await _jellyfinMusicVideoLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), _ => Unit.Default }; diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs new file mode 100644 index 000000000..94705a0b1 --- /dev/null +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs @@ -0,0 +1,216 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Scanner.Core.Interfaces; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Scanner.Core.Jellyfin; + +public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanner +{ + private const string UnknownArtist = "Unknown Artist"; + + private readonly IArtistRepository _artistRepository; + private readonly IJellyfinApiClient _jellyfinApiClient; + private readonly IJellyfinPathReplacementService _pathReplacementService; + private readonly ILibraryRepository _libraryRepository; + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IMetadataRepository _metadataRepository; + private readonly IMusicVideoRepository _musicVideoRepository; + private readonly IScannerProxy _scannerProxy; + + public JellyfinMusicVideoLibraryScanner( + IScannerProxy scannerProxy, + IJellyfinApiClient jellyfinApiClient, + IJellyfinPathReplacementService pathReplacementService, + IMediaSourceRepository mediaSourceRepository, + IArtistRepository artistRepository, + IMusicVideoRepository musicVideoRepository, + ILibraryRepository libraryRepository, + IMetadataRepository metadataRepository, + ILogger logger) + { + _scannerProxy = scannerProxy; + _jellyfinApiClient = jellyfinApiClient; + _pathReplacementService = pathReplacementService; + _mediaSourceRepository = mediaSourceRepository; + _artistRepository = artistRepository; + _musicVideoRepository = musicVideoRepository; + _libraryRepository = libraryRepository; + _metadataRepository = metadataRepository; + _logger = logger; + } + + public async Task> ScanLibrary( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + bool deepScan, + CancellationToken cancellationToken) + { + try + { + Option maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone(); + return await maybeLibraryPath.Match( + libraryPath => ScanLibrary(connectionParameters, library, libraryPath, cancellationToken), + () => Task.FromResult>( + BaseError.New($"Jellyfin library {library.Id} has no library path"))); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + } + + private async Task> ScanLibrary( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + LibraryPath libraryPath, + CancellationToken cancellationToken) + { + List pathReplacements = + await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId); + + var processed = 0; + await foreach ((MusicVideo incoming, int totalCount) in _jellyfinApiClient + .GetMusicVideoLibraryItems( + connectionParameters.Address, + connectionParameters.AuthorizationHeader, + library) + .WithCancellation(cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + + processed++; + decimal percentCompletion = totalCount == 0 ? 1 : Math.Clamp((decimal)processed / totalCount, 0, 1); + if (!await _scannerProxy.UpdateProgress(percentCompletion, cancellationToken)) + { + return new ScanCanceled(); + } + + Either> maybeMusicVideo = + await ProcessMusicVideo(library, libraryPath, pathReplacements, incoming, cancellationToken); + + foreach (BaseError error in maybeMusicVideo.LeftToSeq()) + { + _logger.LogWarning("Error processing Jellyfin music video: {Error}", error.Value); + } + + foreach (MediaItemScanResult result in maybeMusicVideo.RightToSeq() + .Filter(result => result.IsAdded || result.IsUpdated)) + { + if (!await _scannerProxy.ReindexMediaItems([result.Item.Id], cancellationToken)) + { + _logger.LogWarning("Failed to reindex media items from scanner process"); + } + } + } + + return Unit.Default; + } + + private async Task>> ProcessMusicVideo( + JellyfinLibrary library, + LibraryPath libraryPath, + List pathReplacements, + MusicVideo incoming, + CancellationToken cancellationToken) + { + string localPath = GetLocalPath(pathReplacements, incoming); + string folder = Path.GetDirectoryName(localPath) ?? libraryPath.Path; + Option maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, folder, cancellationToken); + LibraryFolder libraryFolder = await _libraryRepository.GetOrAddFolder(libraryPath, maybeParentFolder, folder); + + return await GetOrAddArtist(libraryPath, incoming) + .BindT(artist => _musicVideoRepository.GetOrAdd(artist, libraryPath, libraryFolder, localPath)) + .BindT(result => UpdateMusicVideo(result, incoming, localPath, cancellationToken)); + } + + private string GetLocalPath(List pathReplacements, MusicVideo musicVideo) => + _pathReplacementService.GetReplacementJellyfinPath( + pathReplacements, + musicVideo.GetHeadVersion().MediaFiles.Head().Path, + false); + + private async Task> GetOrAddArtist(LibraryPath libraryPath, MusicVideo musicVideo) + { + string artistName = musicVideo.MusicVideoMetadata + .HeadOrNone() + .Bind(metadata => Optional(metadata.Artists).Flatten().HeadOrNone()) + .Map(artist => artist.Name) + .IfNone(UnknownArtist); + + var metadata = new ArtistMetadata + { + MetadataKind = MetadataKind.External, + Title = artistName, + SortTitle = SortTitle.GetSortTitle(artistName), + DateAdded = DateTime.UtcNow, + Genres = [], + Styles = [], + Moods = [], + Artwork = [], + Guids = [], + Subtitles = [] + }; + + Option maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPath.Id, metadata); + foreach (Artist artist in maybeArtist) + { + return artist; + } + + Either> result = + await _artistRepository.AddArtist(libraryPath.Id, artistName, metadata); + return result.Map(scanResult => scanResult.Item); + } + + private async Task>> UpdateMusicVideo( + MediaItemScanResult result, + MusicVideo incoming, + string localPath, + CancellationToken cancellationToken) + { + result.LocalPath = localPath; + MusicVideoMetadata incomingMetadata = incoming.MusicVideoMetadata.Head(); + + bool updated = await UpdateMetadata(result.Item, incomingMetadata); + updated = await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()) || updated; + + if (updated) + { + result.IsUpdated = true; + } + + return result; + } + + private async Task UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming) + { + Option maybeExisting = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone(); + foreach (MusicVideoMetadata existing in maybeExisting) + { + existing.Title = incoming.Title; + existing.SortTitle = incoming.SortTitle; + existing.Plot = incoming.Plot; + existing.Year = incoming.Year; + existing.ReleaseDate = incoming.ReleaseDate; + existing.DateUpdated = DateTime.UtcNow; + existing.MetadataKind = MetadataKind.External; + + return await _metadataRepository.Update(existing); + } + + incoming.MusicVideoId = musicVideo.Id; + musicVideo.MusicVideoMetadata = [incoming]; + return await _metadataRepository.Add(incoming); + } +} diff --git a/ErsatzTV.Scanner/Program.cs b/ErsatzTV.Scanner/Program.cs index b0aa3a450..7fad0e835 100644 --- a/ErsatzTV.Scanner/Program.cs +++ b/ErsatzTV.Scanner/Program.cs @@ -236,6 +236,7 @@ public class Program services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); -- 2.47.3 From e170b2dc3c2f56fc6387df847356208297c07e1c Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 1 Jul 2026 19:37:51 +0200 Subject: [PATCH 0008/1118] fix(ffmpeg): align transcode pipelines with ffmpeg 8 refs #9 --- .../QsvHardwareAccelerationOptionTests.cs | 38 +++++++++++++++++++ .../QsvHardwareAccelerationOption.cs | 24 +++++++----- docker/Dockerfile | 4 +- docker/ffmpeg-tests/Dockerfile | 7 ++-- docs/ci-cd.md | 6 +-- docs/fork-strategy.md | 2 +- 6 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 ErsatzTV.FFmpeg.Tests/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOptionTests.cs diff --git a/ErsatzTV.FFmpeg.Tests/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOptionTests.cs b/ErsatzTV.FFmpeg.Tests/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOptionTests.cs new file mode 100644 index 000000000..01c290b09 --- /dev/null +++ b/ErsatzTV.FFmpeg.Tests/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOptionTests.cs @@ -0,0 +1,38 @@ +using ErsatzTV.FFmpeg.Capabilities; +using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; + +namespace ErsatzTV.FFmpeg.Tests.GlobalOption.HardwareAcceleration; + +[TestFixture] +public class QsvHardwareAccelerationOptionTests +{ + [Test] + public void GlobalOptions_WithDevice_ShouldDeriveQsvDeviceFromVaapiDevice() + { + var option = new QsvHardwareAccelerationOption("/dev/dri/renderD128", FFmpegCapability.Software); + + option.GlobalOptions.ShouldBe( + [ + "-init_hw_device", "vaapi=va:/dev/dri/renderD128", + "-init_hw_device", "qsv=hw@va", + "-filter_hw_device", "hw" + ]); + } + + [Test] + public void GlobalOptions_WithHardwareDecode_ShouldEnableQsvHwaccel() + { + var option = new QsvHardwareAccelerationOption(None, FFmpegCapability.Hardware); + + option.GlobalOptions.ShouldBe( + [ + "-hwaccel", "qsv", + "-hwaccel_output_format", "qsv", + "-init_hw_device", "qsv=hw", + "-filter_hw_device", "hw" + ]); + } +} diff --git a/ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs b/ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs index 9754b9cda..e4691fac6 100644 --- a/ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs +++ b/ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs @@ -16,8 +16,6 @@ public class QsvHardwareAccelerationOption(Option device, FFmpegCapabili { get { - string[] initDevices = ["-init_hw_device", "qsv=hw", "-filter_hw_device", "hw"]; - var result = new List { "-hwaccel", "qsv", @@ -29,18 +27,26 @@ public class QsvHardwareAccelerationOption(Option device, FFmpegCapabili result.Clear(); } - if (OperatingSystem.IsLinux()) + var deviceConfigured = false; + foreach (string qsvDevice in device) { - foreach (string qsvDevice in device) + if (!string.IsNullOrWhiteSpace(qsvDevice)) { - if (!string.IsNullOrWhiteSpace(qsvDevice)) - { - result.AddRange(new[] { "-qsv_device", qsvDevice }); - } + result.AddRange( + [ + "-init_hw_device", $"vaapi=va:{qsvDevice}", + "-init_hw_device", "qsv=hw@va" + ]); + deviceConfigured = true; } } - result.AddRange(initDevices); + if (!deviceConfigured) + { + result.AddRange(["-init_hw_device", "qsv=hw"]); + } + + result.AddRange(["-filter_hw_device", "hw"]); return result.ToArray(); } diff --git a/docker/Dockerfile b/docker/Dockerfile index f468b1703..529cbb468 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-amd64 AS dotnet-runtime -FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1 AS runtime-base +FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet RUN apt-get update && \ apt-get install -y --no-install-recommends python3 python3-pip && \ @@ -40,7 +40,7 @@ COPY ErsatzTV.Infrastructure/*.csproj ./ErsatzTV.Infrastructure/ COPY ErsatzTV.Infrastructure.Sqlite/*.csproj ./ErsatzTV.Infrastructure.Sqlite/ COPY ErsatzTV.Infrastructure.MySql/*.csproj ./ErsatzTV.Infrastructure.MySql/ COPY ErsatzTV.Scanner/*.csproj ./ErsatzTV.Scanner/ -RUN dotnet restore -r linux-x64 ErsatzTV/ +RUN dotnet restore -r linux-x64 ErsatzTV/ErsatzTV.csproj # copy everything else and build app COPY ErsatzTV/. ./ErsatzTV/ diff --git a/docker/ffmpeg-tests/Dockerfile b/docker/ffmpeg-tests/Dockerfile index 5f3cedef4..4708f992c 100644 --- a/docker/ffmpeg-tests/Dockerfile +++ b/docker/ffmpeg-tests/Dockerfile @@ -1,14 +1,15 @@ -FROM ghcr.io/ersatztv/ersatztv-ffmpeg:7.1.1 +FROM 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 RUN apt-get update && apt-get install -y ca-certificates gnupg mkvtoolnix && \ curl -L https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh && \ chmod +x ./dotnet-install.sh && \ - ./dotnet-install.sh --channel 9.0 + ./dotnet-install.sh --channel 10.0 ENV DOTNET_ROOT="/root/.dotnet" ENV PATH="$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools" WORKDIR /source # copy csproj and restore as distinct layers COPY *.sln . +COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json .editorconfig ./ COPY artwork/* ./artwork/ COPY ErsatzTV/*.csproj ./ErsatzTV/ COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/ @@ -20,7 +21,7 @@ COPY ErsatzTV.Infrastructure.Sqlite/*.csproj ./ErsatzTV.Infrastructure.Sqlite/ COPY ErsatzTV.Infrastructure.MySql/*.csproj ./ErsatzTV.Infrastructure.MySql/ COPY ErsatzTV.Scanner/*.csproj ./ErsatzTV.Scanner/ COPY ErsatzTV.Scanner.Tests/*.csproj ./ErsatzTV.Scanner.Tests/ -RUN dotnet restore -r linux-x64 ErsatzTV/ +RUN dotnet restore -r linux-x64 ErsatzTV/ErsatzTV.csproj # copy everything else and build app COPY ErsatzTV/. ./ErsatzTV/ diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 0bffb0fd3..a5e115929 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -75,9 +75,9 @@ the image build. ## Dockerfile notes (`docker/Dockerfile`) -- Base image: **`192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1`** (our Gitea fork of - the archived `ghcr.io/ersatztv/ersatztv-ffmpeg`). FFmpeg 8 upgrade is backlogged: - base image → ersatztv-ffmpeg#4, app-side compat → ersatztv#9. +- Base image: **`192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2`** (our Gitea fork of + the archived `ghcr.io/ersatztv/ersatztv-ffmpeg`). FFmpeg 8 base image work landed in + ersatztv-ffmpeg#4; app-side compatibility work landed in ersatztv#9. - Copies `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`, `global.json`, `.editorconfig` before `dotnet restore` so the image build uses the same MSBuild config, central package versions, SDK pin, and analyzer severities as local/CI diff --git a/docs/fork-strategy.md b/docs/fork-strategy.md index 680f75ea9..731d7d1dd 100644 --- a/docs/fork-strategy.md +++ b/docs/fork-strategy.md @@ -24,7 +24,7 @@ We diverge freely from upstream's final state. There is no upstream to merge fro ### Docker Base Images - .NET SDK/runtime images (`mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64`) — update when .NET patches ship. -- FFmpeg image: forked separately at [timothy/ersatztv-ffmpeg](http://192.168.1.95:3000/timothy/ersatztv-ffmpeg). Currently `192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1`. The main Dockerfile still references the upstream `ghcr.io` image and needs updating. +- FFmpeg image: forked separately at [timothy/ersatztv-ffmpeg](http://192.168.1.95:3000/timothy/ersatztv-ffmpeg). Currently `192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2`. ### CVE Response -- 2.47.3 From a2d2f5930c1835ed2619ced17e415fd1064902ee Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 1 Jul 2026 19:59:18 +0200 Subject: [PATCH 0009/1118] chore: stop tracking local MCP config --- .gitignore | 3 +++ .mcp.json | 56 ------------------------------------------------------ 2 files changed, 3 insertions(+), 56 deletions(-) delete mode 100644 .mcp.json diff --git a/.gitignore b/.gitignore index 353cfd6ed..f276affa0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ project.lock.json # Claude Code .mcp/ +.mcp.json +.agents/ +plugins/ nupkg/ # Visual Studio Code diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index e278f4e5f..000000000 --- a/.mcp.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "mcpServers": { - "docker-mcp": { - "command": "uvx", - "args": [ - "mcp-server-docker" - ], - "env": { - "DOCKER_HOST": "ssh://timothy@192.168.1.99" - } - }, - "ssh-mcp": { - "command": "npx", - "args": [ - "-y", - "ssh-mcp", - "--", - "--host=192.168.1.99", - "--user=timothy" - ], - "env": {} - }, - "gitea": { - "command": "gitea-mcp-server", - "args": [ - "-t", "stdio", - "-host", "http://192.168.1.95:3000", - "-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67" - ], - "env": {} - }, - "csharp-lsp": { - "command": "/usr/local/share/dotnet/dotnet", - "args": [ - "run", - "--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp", - "-c", "Release" - ], - "env": { - "PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin" - } - }, - "nuget": { - "command": "/usr/local/share/dotnet/dotnet", - "args": [ - "dnx", - "NuGet.Mcp.Server", - "--source", "https://api.nuget.org/v3/index.json", - "--yes" - ], - "env": { - "DOTNET_ROOT": "/usr/local/share/dotnet" - } - } - } -} -- 2.47.3 From 0826198d21ce8ceaf06e83ba4ff4bc70f07239bd Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 1 Jul 2026 20:58:12 +0200 Subject: [PATCH 0010/1118] docs: add ChicoryTV UI redesign visual-direction brief (Pass 1) Records the locked design direction for #59: React SPA on the REST API, API-first (design pulls the API), Komodo-slick look, ChicoryTV dark + chicory-blue brand. Includes the 4 hero screens with real domain vocabulary and ready-to-paste Claude Design prompts. Refs #59 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-07-01-chicorytv-ui-redesign-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md diff --git a/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md new file mode 100644 index 000000000..6658e5e2d --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md @@ -0,0 +1,134 @@ +# ChicoryTV UI Redesign — Visual Direction Brief (Pass 1) + +**Date:** 2026-07-01 +**Issue:** [ersatztv#59 — full UI redesign and ChicoryTV rebrand](http://192.168.1.95:3000/timothy/ersatztv/issues/59) +**Status:** Design direction locked; this brief drives the first Claude Design pass. + +> Scope of *this* document: the **visual-direction brief** for Pass 1 — the decisions plus the exact prompts to paste into Claude Design (claude.ai/design) to generate 4 hero screens. It is **not** the full #59 implementation spec. That comes after a look is locked. + +--- + +## 1. Context & goals + +ErsatzTV is a custom IPTV channel server (C#/.NET 10, currently a Blazor Server + MudBlazor UI, ~60 Razor pages). The current UI is utilitarian ("Sonarr-like") and was never designed. Issue #59 calls for a complete redesign built **around the REST API** rather than legacy page patterns, plus a rename to **ChicoryTV**, with drag-and-drop workflows, better creation defaults/templates, and discoverable-but-not-heavy advanced config. + +**Goals (from #59):** +- Slick, modern, app-like UI built around the API. +- Drag-and-drop for channel lineup, scheduling, ordering, editing. +- Better defaults/templates for channel creation (subtitles, shuffle, schedule/playout defaults). +- Advanced config discoverable without making common workflows heavy. +- Rebrand user-facing surfaces to ChicoryTV. + +**Success for Pass 1:** the user (not design-oriented) can look at 4 rendered hero screens and say "yes, that direction" — locking a look + brand cheaply before any spec/prototype/build work. + +--- + +## 2. Strategic decisions (locked 2026-07-01) + +| Decision | Choice | Why | +|---|---|---| +| **Frontend stack** | **React SPA on the REST API** (not Blazor/MudBlazor reskin) | All design is AI-driven; LLMs generate React far more reliably than Blazor. Claude Design outputs React, so its output is *shippable* and the `design-sync` pipeline becomes usable (on-brand AI designs → 1:1 shipped code). Satisfies #59's "around the API" goal natively. | +| **API sequencing** | **Design pulls the API** | If a design goal needs a new API feature, build the API feature first. #59 is *not* hard-blocked on API completeness. | +| **Look & feel** | **Komodo-slick** | Dynamic, app-like, no full-page reloads, dense/technical. Explicitly **not** Sonarr-style. | +| **Brand** | **ChicoryTV — dark + chicory blue** | See §3. | + +**Accepted costs of React:** full UI rewrite, dual-language (C# backend + TS frontend), new build/deploy step, transition period running both UIs. + +**Pipeline note:** `/design-sync` does **not** apply yet — MudBlazor isn't a React library. It becomes usable once a React component library exists, at which point real ChicoryTV components can be synced into Claude Design. + +--- + +## 3. Brand: ChicoryTV + +"Chicory" is a real plant with a distinctive cornflower/periwinkle-blue flower (and a coffee/roasted association) — giving an ownable accent color and a name with a story. Direction: **dark, technical, focused**, one confident accent. + +### Color tokens (draft — refine in Claude Design) + +``` +bg #0F1115 (near-black charcoal — app background) +surface #171A21 (cards / panels) +border #262B36 (hairline dividers) +text #E6E9EF (primary) muted #9AA4B2 (secondary) +accent #5B7CFA (chicory periwinkle-blue — primary actions, active nav) +ok #3FB984 warn #E0A83D err #E5484D +``` + +### Usage rules +- Accent used **sparingly** — primary buttons, active nav item, focus/selection, "live/on-air" emphasis. Not decorative. +- Dense rows with subtle hairline borders; generous but not wasteful spacing. TV/EPG grids read well on dark. +- **Typography:** clean sans (Inter-like) for UI; consider a tabular/mono for channel numbers, times, and durations so grids align. +- **Density:** app-like, information-dense (Komodo), not marketing-spacious. Prefer inline actions and hover affordances over full-page navigation. +- **Logo/wordmark:** "ChicoryTV" wordmark with a chicory-flower/periwinkle mark; let Claude Design propose 2-3 options. + +--- + +## 4. The 4 hero screens (Pass 1) + +Real domain vocabulary is included so renders show true ChicoryTV content, not placeholders. **Use the app's own terms:** Channels, Schedules / Schedule Items, Playouts (Classic/Block/Sequential/Scripted/External JSON), Blocks, Playout Templates, Deco / Deco Group, Filler (Pre/Mid/Post-Roll, Tail, Fallback), Collection Type, Playback Order, Playout Mode, Guide Mode, and the XMLTV "programme" for EPG. + +### 4.1 Dashboard (reimagined as a real overview) +Today `Index.razor` is only health-checks + release notes. **Redesign it into a proper landing overview:** +- **On-air now** — per-channel "now playing" strip/cards: channel number + logo + name, current program title, progress bar to next, live/on-air accent state. +- **At-a-glance counts** — channels, active playouts, transcodes running, libraries. +- **Health panel** — the existing `HealthCheckResult` list (status: Pass/Info/Warning/Fail with green/blue/amber/red), condensed; click-through to detail. +- **Recent activity / errors** — recent scans, playout resets, transcode failures. +- **Release notes** — collapsible card, secondary. + +### 4.2 Channels list +Entity: `Channel`. Dense table, the template for all list pages. +- **Columns:** Number (with `(D)` disabled / `(H)` hidden-from-EPG markers) · Logo · Name · Language (preferred audio) · Streaming Mode (`HLS Direct` / `HLS Segmenter` / `MPEG-TS` / `MPEG-TS (Legacy)`) · FFmpeg Profile. +- **Row actions (inline icons):** Preview · Stop transcode (only when active) · Edit · Troubleshoot playback · Delete. +- **Top bar:** "Add Channel", "Edit Channel Numbers", "Show Disabled" toggle, search/filter. +- **States:** disabled/hidden rows visually de-emphasized; active-transcode indicator. +- **Redesign opportunity:** bulk-select + bulk actions; group-by channel Group; drag-to-reorder channel numbers (replaces the separate "Edit Channel Numbers" page). + +### 4.3 Schedule / playout editor (introduce drag-and-drop) +**Net-new capability:** today, schedule-item ordering is up/down arrow buttons. The redesign introduces **drag-to-reorder**. +- **Left:** ordered list of **Schedule Items** (drag handles) — each row shows Start Time · Collection · Playout Mode. +- **Right:** selected-item detail form. Real fields: + - **Start Type** (Dynamic/Fixed) + Start Time + Fixed Start Time Behavior (Inherit/Strict/Flexible) + - **Collection Type** — Collection, Television Show/Season, Artist, Multi Collection, Smart Collection, Search Query, Playlist, Rerun (First Run / Rerun) — each swaps its picker. + - **Playback Order** — Chronological, Random, Shuffle, Shuffle In Order, Marathon, Season/Episode, Multi-Episode Shuffle (+ Marathon sub-fields: Group By, Shuffle Groups/Items, Batch Size). + - **Playout Mode** — Flood / One / Multiple / Duration (+ Multiple Mode & Count, Playout Duration). + - **Fill With Group Mode, Tail Mode, Custom Title, Guide Mode (Normal/Filler)**. + - **Filler** — Pre-Roll, Mid-Roll, Post-Roll, Tail, Fallback presets. + - **Overrides** — Watermarks, Graphics, Preferred Audio/Subtitle Language, Subtitle Mode. +- **Discoverability:** show the common fields (Collection, Playback Order, Playout Mode) up front; tuck Filler/Overrides into progressive-disclosure sections. + +### 4.4 Guide / EPG grid +Today the guide is XMLTV output only (no rendered grid). Design a **dark time-grid** program guide. +- **Layout:** channels down the left (number + logo + name), time across the top, program blocks in the grid. +- **Program block** (`programme`): title, sub-title, start–stop times, category chip, artwork/icon, description on hover/expand. +- **States:** "now" vertical time-marker; current/on-air program gets the accent; filler (Guide Mode = Filler) visually distinct from real programs. +- **Controls:** time-range scrubber, jump-to-now, channel filter. + +--- + +## 5. How to use this in Claude Design + +Open claude.ai/design and create a project (e.g. "ChicoryTV"). Paste the **master brief** first, then generate one screen at a time with the per-screen prompts. Iterate on the look on the Dashboard first (it sets the shell), then apply the locked style to the rest. + +### Master brief (paste first) +> Design **ChicoryTV**, a dark, slick, app-like admin UI for a self-hosted IPTV/custom-TV channel server (think a modern take on a media-server admin, in the spirit of Komodo — dense, technical, no full-page reloads). Dark theme: background `#0F1115`, surfaces `#171A21`, hairline borders `#262B36`, primary text `#E6E9EF`, muted `#9AA4B2`. One accent — chicory periwinkle-blue `#5B7CFA` — used only for primary actions, active nav, focus, and live/on-air states. Status colors: ok `#3FB984`, warn `#E0A83D`, error `#E5484D`. Clean sans UI type; tabular/mono for numbers, times, durations. Left sidebar nav (Dashboard, Channels, Schedules, Playouts, Collections, Libraries, Settings), compact top bar. Information-dense, inline actions, hover affordances. Include a "ChicoryTV" wordmark with a small periwinkle chicory-flower mark. + +### Per-screen prompts +1. **Dashboard:** "A landing overview: an 'On air now' section of per-channel cards (channel number, logo, name, current program title, progress bar to next, live accent), a row of stat tiles (Channels, Active Playouts, Transcodes, Libraries), a condensed Health panel (rows with Pass/Info/Warning/Fail status icons in green/blue/amber/red), a Recent activity list, and a collapsible Release notes card." +2. **Channels list:** "A dense channels table. Columns: Number, Logo, Name, Language, Streaming Mode (values like 'HLS Segmenter', 'MPEG-TS'), FFmpeg Profile. Inline row actions: preview, edit, delete. Top bar: 'Add Channel', 'Show Disabled' toggle, search. Disabled rows de-emphasized. Bulk-select checkboxes." +3. **Schedule editor:** "Two-pane schedule editor. Left: a drag-to-reorder list of schedule items (drag handles), each showing start time, collection name, and playout mode. Right: a detail form for the selected item with Collection Type, Playback Order, and Playout Mode as the primary fields, and collapsible 'Filler' and 'Overrides' sections. Dark, dense." +4. **EPG grid:** "A dark TV program guide grid: channels down the left (number, logo, name), time across the top, program blocks in the grid with title/sub-title and a category chip. A vertical 'now' time marker; the current program on each channel uses the accent color; filler programs look muted. A jump-to-now button and time scrubber." + +Refine tokens/typography live, then regenerate the other screens once the Dashboard look is locked. + +--- + +## 6. Out of scope (Pass 1) & next steps + +**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), API gap analysis, channel-create wizard + templates screen, media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. + +**Next steps (after a look is locked):** +1. Pass 2 screens: channel-create wizard + templates, libraries/collections browser. +2. Prototype the hard workflows (drag-drop lineup/scheduling) as clickable React. +3. Full #59 spec: IA, React app architecture, API gaps to backfill, migration/cutover (dual-stack), rebrand checklist. +4. Build. + +**Related:** #2 (REST API — the foundation this UI sits on), #58 (MCP server), #60 (browser channel playback, post-redesign). -- 2.47.3 From d1dfe6eb5a861b16288f93e9b4b01a6f272b5275 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 1 Jul 2026 22:39:16 +0200 Subject: [PATCH 0011/1118] 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. -- 2.47.3 From 4229717575d8690259bb3395eab76faab22b0ad0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 1 Jul 2026 23:00:09 +0200 Subject: [PATCH 0012/1118] docs: add Create Channel workflow redesign + backend gaps Replaces the inherited 4-editor channel-creation chain with a single library-to-lineup builder: browse/search library with artwork, drag shows into the lineup, friendly toggles (shuffle, always-on), one image for logo+bug, technical settings via built-in/custom Channel Templates with advanced override. Lists prerequisite backend gaps (composite create endpoint, Channel Templates, library browse API, full image-type support incl. transparent re-encode, unified logo/bug, always-on vs on-demand) and a Claude Design prompt for the screen. Refs #59 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-07-01-chicorytv-ui-redesign-design.md | 53 ++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md index 6658e5e2d..5ae6cf0c8 100644 --- a/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md +++ b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md @@ -121,14 +121,55 @@ Refine tokens/typography live, then regenerate the other screens once the Dashbo --- -## 6. Out of scope (Pass 1) & next steps +## 6. Create Channel — workflow redesign (pulled forward) -**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), API gap analysis, channel-create wizard + templates screen, media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. +The inherited channel-creation flow is the biggest UX pain and is being fully rethought. Today, making a working channel is a chain of four disconnected editors across four entities — **Channel → Schedule → Schedule Items → Playout** — and the channel form alone dumps ~30 advanced fields with no sane defaults. There is no way to browse your library while building, and no templates/presets. + +### Target experience: one library-to-lineup builder + +**One screen** replaces the four-editor chain. Behind the scenes it still creates Channel + Collection + Schedule + Schedule Items + Playout — the user never sees those concepts. + +- **Left — Library browser:** artwork grid of shows/movies, searchable, filterable by library. (Wholly missing today.) +- **Right — Channel lineup:** items added from the library, drag-to-reorder. This *is* the channel's content. +- **Add by** double-click **or** drag. + +**Friendly options, always visible (the 90% case):** +- **Name** + auto-suggested channel **Number**. +- **Shuffle** toggle (on = Shuffle playback order; off = Sequential/Chronological). +- **"Always playing (like live TV)"** toggle — plays continuously and advances on schedule even when unwatched. *(Needs backend confirmation — see gap 6.)* +- **Channel image:** drop one image, used by default as **both** the listing **logo** and the on-screen **bug** (watermark), with live preview of both; expandable to set them separately (as possible today). + +**Technical settings = a Channel Template (with override):** +- A **Channel Template** picker bundles the clunky technical/behavioral config — FFmpeg/transcode profile, fillers (pre/mid/post-roll), interleaving, subtitle behavior, playout defaults. A sensible default template is pre-selected, so most channels need **zero** technical config. +- **Both built-in and custom templates:** ship a few sensible built-ins (e.g. "Standard", "Music videos"); users can save their own. Where a template can supply a default, it does — templates are the assumed path. +- **"Advanced"** disclosure reveals the full ~30-field set for power-user overrides. + +**One "Create" button** builds all underlying entities atomically. + +### Backend gaps this requires (design pulls the API — file as prerequisite issues) + +1. **Composite "create channel from lineup" endpoint** — creates channel + collection + schedule + schedule items + playout atomically (today they are separate operations). +2. **Channel Templates entity** — bundles transcode profile, fillers, interleave, subtitle/shuffle/playout defaults; built-in + user-saved. (#59 explicitly asks for templates.) +3. **Library browse + search API with artwork** — powers the left pane. +4. **Full image-type support for logo/watermark** — WebP appears unsupported today. Support can mean *either* adding decode support *or* **transparently re-encoding uploaded images to a supported format on upload** if FFmpeg/SkiaSharp is the blocker — the user should never hit an "unsupported image" wall. +5. **Unified logo/bug image handling** — one uploaded image drives both, separable. +6. **"Always-on vs on-demand" playout** — needs investigation; ErsatzTV playouts are continuous by design, so an on-demand mode may not exist yet. + +### Claude Design prompt — Create Channel + +> A "Create Channel" screen for a dark IPTV admin (ChicoryTV palette). Two main panes: LEFT is a searchable library browser — a grid of show/movie poster artwork with a search bar and a library filter dropdown. RIGHT is the "channel lineup" — a vertical list of the shows added from the library, each with a drag handle to reorder and a remove button. Above the panes: a channel Name field, an auto-filled channel Number, a Shuffle toggle, an "Always playing (like live TV)" toggle, and a single image drop-zone labelled "Channel image" showing a live preview used as both the listing logo and the on-screen bug, with an "advanced: set separately" link. A "Template" dropdown (default: "Standard") sets technical settings, with an "Advanced settings" collapsible section below it. A prominent "Create channel" button. Dense, app-like, chicory periwinkle-blue accent on the primary button and active elements. + +--- + +## 7. Out of scope & next steps + +**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation was originally deferred to Pass 2 but is now specified in §6.) **Next steps (after a look is locked):** -1. Pass 2 screens: channel-create wizard + templates, libraries/collections browser. -2. Prototype the hard workflows (drag-drop lineup/scheduling) as clickable React. -3. Full #59 spec: IA, React app architecture, API gaps to backfill, migration/cutover (dual-stack), rebrand checklist. -4. Build. +1. Design the §6 Create Channel screen in Claude Design; file the §6 backend-gap issues. +2. Pass 2 screens: libraries/collections browser. +3. Prototype the hard workflows (drag-drop lineup/scheduling, create-channel builder) as clickable React. +4. Full #59 spec: IA, React app architecture, API gaps to backfill, migration/cutover (dual-stack), rebrand checklist. +5. Build. **Related:** #2 (REST API — the foundation this UI sits on), #58 (MCP server), #60 (browser channel playback, post-redesign). -- 2.47.3 From 482396497e7a6084a2efa502a03e8207289a165b Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 1 Jul 2026 23:02:48 +0200 Subject: [PATCH 0013/1118] docs: link Create Channel backend gaps to epic #62 (#63-#68) Refs #59 #62 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-01-chicorytv-ui-redesign-design.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md index 5ae6cf0c8..bec957aca 100644 --- a/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md +++ b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md @@ -146,14 +146,16 @@ The inherited channel-creation flow is the biggest UX pain and is being fully re **One "Create" button** builds all underlying entities atomically. -### Backend gaps this requires (design pulls the API — file as prerequisite issues) +### Backend gaps this requires (design pulls the API) -1. **Composite "create channel from lineup" endpoint** — creates channel + collection + schedule + schedule items + playout atomically (today they are separate operations). -2. **Channel Templates entity** — bundles transcode profile, fillers, interleave, subtitle/shuffle/playout defaults; built-in + user-saved. (#59 explicitly asks for templates.) -3. **Library browse + search API with artwork** — powers the left pane. -4. **Full image-type support for logo/watermark** — WebP appears unsupported today. Support can mean *either* adding decode support *or* **transparently re-encoding uploaded images to a supported format on upload** if FFmpeg/SkiaSharp is the blocker — the user should never hit an "unsupported image" wall. -5. **Unified logo/bug image handling** — one uploaded image drives both, separable. -6. **"Always-on vs on-demand" playout** — needs investigation; ErsatzTV playouts are continuous by design, so an on-demand mode may not exist yet. +Tracked under epic **#62**; sub-issues #63–#68: + +1. **Composite "create channel from lineup" endpoint** (#63) — creates channel + collection + schedule + schedule items + playout atomically (today they are separate operations). +2. **Channel Templates entity** (#64) — bundles transcode profile, fillers, interleave, subtitle/shuffle/playout defaults; built-in + user-saved. (#59 explicitly asks for templates.) +3. **Library browse + search API with artwork** (#65) — powers the left pane. +4. **Full image-type support for logo/watermark** (#66) — WebP appears unsupported today. Support can mean *either* adding decode support *or* **transparently re-encoding uploaded images to a supported format on upload** if FFmpeg/SkiaSharp is the blocker — the user should never hit an "unsupported image" wall. +5. **Unified logo/bug image handling** (#67) — one uploaded image drives both, separable. +6. **"Always-on vs on-demand" playout** (#68) — needs investigation; ErsatzTV playouts are continuous by design, so an on-demand mode may not exist yet. ### Claude Design prompt — Create Channel -- 2.47.3 From c0b20a0ac5b90fbef7fe4f67d1f798ed9770c94c Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 00:45:07 +0200 Subject: [PATCH 0014/1118] docs: add auto-tuning screen + borrowed-concept enrichments to brief Adds the Auto-tuning wizard as hero screen 5 (#69) and annotates the Channels-list and Schedule-editor sections + prompts with the borrowed enrichments: lineup health + resume badge (#72, #68), weighted distribution / reroll / pad-to-boundary / seasonal (#70, #71, #77, #73). Refs #59 #69 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-01-chicorytv-ui-redesign-design.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md index bec957aca..2955ba078 100644 --- a/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md +++ b/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md @@ -81,6 +81,9 @@ Entity: `Channel`. Dense table, the template for all list pages. - **Top bar:** "Add Channel", "Edit Channel Numbers", "Show Disabled" toggle, search/filter. - **States:** disabled/hidden rows visually de-emphasized; active-transcode indicator. - **Redesign opportunity:** bulk-select + bulk actions; group-by channel Group; drag-to-reorder channel numbers (replaces the separate "Edit Channel Numbers" page). +- **Borrowed-concept enrichments:** + - **Lineup health at a glance** (#72) — per-row status: user-made vs auto-generated origin, and a prominent **"failed / no content"** state (empty collection, no playout, broken source). + - **Resume badge** (#68) — sequential/resume channels show a "Continue watching — resumes at S1E4 12:30" indicator. ### 4.3 Schedule / playout editor (introduce drag-and-drop) **Net-new capability:** today, schedule-item ordering is up/down arrow buttons. The redesign introduces **drag-to-reorder**. @@ -94,6 +97,11 @@ Entity: `Channel`. Dense table, the template for all list pages. - **Filler** — Pre-Roll, Mid-Roll, Post-Roll, Tail, Fallback presets. - **Overrides** — Watermarks, Graphics, Preferred Audio/Subtitle Language, Subtitle Mode. - **Discoverability:** show the common fields (Collection, Playback Order, Playout Mode) up front; tuck Filler/Overrides into progressive-disclosure sections. +- **Borrowed-concept enrichments:** + - **Weighted / fair-share distribution** (#70) — per-show weight inputs (sliders or %) when a schedule item mixes multiple shows ("Show A 70% / Show B 30%"), plus a fair-share toggle. + - **Persistent shuffle + Reroll** (#71) — a "Reroll" button and a visible shuffle-state indicator, so shuffle is deliberate and predictable rather than opaque. + - **Pad to clock boundary** (#77) — an option to snap programs to :00/:15/:30 with filler, for tidy broadcast-style guides. + - **Seasonal / date condition** (#73) — an optional date-range predicate on a schedule item ("active only between dates"). ### 4.4 Guide / EPG grid Today the guide is XMLTV output only (no rendered grid). Design a **dark time-grid** program guide. @@ -113,12 +121,15 @@ Open claude.ai/design and create a project (e.g. "ChicoryTV"). Paste the **maste ### Per-screen prompts 1. **Dashboard:** "A landing overview: an 'On air now' section of per-channel cards (channel number, logo, name, current program title, progress bar to next, live accent), a row of stat tiles (Channels, Active Playouts, Transcodes, Libraries), a condensed Health panel (rows with Pass/Info/Warning/Fail status icons in green/blue/amber/red), a Recent activity list, and a collapsible Release notes card." -2. **Channels list:** "A dense channels table. Columns: Number, Logo, Name, Language, Streaming Mode (values like 'HLS Segmenter', 'MPEG-TS'), FFmpeg Profile. Inline row actions: preview, edit, delete. Top bar: 'Add Channel', 'Show Disabled' toggle, search. Disabled rows de-emphasized. Bulk-select checkboxes." -3. **Schedule editor:** "Two-pane schedule editor. Left: a drag-to-reorder list of schedule items (drag handles), each showing start time, collection name, and playout mode. Right: a detail form for the selected item with Collection Type, Playback Order, and Playout Mode as the primary fields, and collapsible 'Filler' and 'Overrides' sections. Dark, dense." +2. **Channels list:** "A dense channels table. Columns: Number, Logo, Name, Language, Streaming Mode (values like 'HLS Segmenter', 'MPEG-TS'), FFmpeg Profile. Inline row actions: preview, edit, delete. Top bar: 'Add Channel', 'Show Disabled' toggle, search. Disabled rows de-emphasized. Bulk-select checkboxes. Each row has a small status dot — green for healthy, red for 'no content / won't play', and a subtle 'auto' tag for auto-generated channels; a couple of rows show a 'Continue watching — resumes at S1E4' badge." +3. **Schedule editor:** "Two-pane schedule editor. Left: a drag-to-reorder list of schedule items (drag handles), each showing start time, collection name, and playout mode. Right: a detail form for the selected item with Collection Type, Playback Order, and Playout Mode as the primary fields, and collapsible 'Filler' and 'Overrides' sections. Show a 'Reroll' button next to a shuffle indicator; when the item mixes multiple shows, show per-show weight sliders; include a 'Pad to :00/:30' toggle and an optional date-range 'active between' condition. Dark, dense." 4. **EPG grid:** "A dark TV program guide grid: channels down the left (number, logo, name), time across the top, program blocks in the grid with title/sub-title and a category chip. A vertical 'now' time marker; the current program on each channel uses the accent color; filler programs look muted. A jump-to-now button and time scrubber." +5. **Auto-tuning wizard** *(borrowed from PseudoTV, #69)*: "A 'Generate channels' screen. Step 1: choose what to generate channels from — a set of toggle cards labelled 'By TV Network', 'By TV Genre', 'By Movie Genre', 'By Studio', 'One per Show (24/7)', 'By Smart Collection'. Step 2: a preview grid of the channels that would be created — each a card with an auto-picked logo, a channel name (e.g. 'Comedy', 'Warner Bros.', 'Breaking Bad 24/7'), an item count, and an include checkbox. A 'Template' dropdown applies default settings to all. A prominent 'Create 12 channels' button. Dense, app-like." Refine tokens/typography live, then regenerate the other screens once the Dashboard look is locked. +> **Enrichments cross-reference:** the annotations above map to filed issues — Channels-list status/resume (#72, #68); Schedule-editor weights/reroll/pad/seasonal (#70, #71, #77, #73); Auto-tuning (#69). These enrich the hero screens; they're not separate deliverables except the Auto-tuning wizard (screen 5). + --- ## 6. Create Channel — workflow redesign (pulled forward) -- 2.47.3 From b183c6a1543e4a9f0f11c8ee4b202d8030b84d31 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 07:37:00 +0200 Subject: [PATCH 0015/1118] feat(frontend): scaffold ChicoryTV SPA refs #78 --- .gitea/workflows/docker-build.yml | 27 + .gitignore | 3 + ErsatzTV.Tests/StartupSpaHostingTests.cs | 45 + ErsatzTV/Startup.cs | 47 +- design-system/Canvas.dc.html | 13 + design-system/SKILL.md | 17 + design-system/_adherence.oxlintrc.json | 470 ++ design-system/_ds_bundle.js | 3260 ++++++++++++++ design-system/_ds_manifest.json | 1 + design-system/assets/chicory-mark.svg | 8 + design-system/assets/chicorytv-icon.svg | 9 + design-system/assets/chicorytv-wordmark.svg | 9 + .../components/data-display/Badge.d.ts | 17 + .../components/data-display/Badge.jsx | 39 + .../components/data-display/Badge.prompt.md | 11 + .../components/data-display/Card.d.ts | 23 + .../components/data-display/Card.jsx | 38 + .../components/data-display/Card.prompt.md | 10 + .../components/data-display/ChannelLogo.d.ts | 17 + .../components/data-display/ChannelLogo.jsx | 51 + .../data-display/ChannelLogo.prompt.md | 8 + .../components/data-display/ProgressBar.d.ts | 16 + .../components/data-display/ProgressBar.jsx | 32 + .../data-display/ProgressBar.prompt.md | 8 + .../components/data-display/Stat.d.ts | 18 + .../components/data-display/Stat.jsx | 23 + .../components/data-display/Stat.prompt.md | 9 + .../components/data-display/StatusDot.d.ts | 16 + .../components/data-display/StatusDot.jsx | 37 + .../data-display/StatusDot.prompt.md | 9 + .../components/data-display/Tag.d.ts | 16 + design-system/components/data-display/Tag.jsx | 54 + .../components/data-display/Tag.prompt.md | 8 + .../data-display/data-display.card.html | 71 + .../components/feedback/Spinner.d.ts | 13 + design-system/components/feedback/Spinner.jsx | 17 + .../components/feedback/Spinner.prompt.md | 7 + design-system/components/feedback/Toast.d.ts | 16 + design-system/components/feedback/Toast.jsx | 46 + .../components/feedback/Toast.prompt.md | 8 + .../components/feedback/Tooltip.d.ts | 13 + design-system/components/feedback/Tooltip.jsx | 45 + .../components/feedback/Tooltip.prompt.md | 7 + .../components/feedback/feedback.card.html | 50 + design-system/components/forms/Button.d.ts | 27 + design-system/components/forms/Button.jsx | 107 + .../components/forms/Button.prompt.md | 11 + design-system/components/forms/Checkbox.d.ts | 16 + design-system/components/forms/Checkbox.jsx | 37 + .../components/forms/Checkbox.prompt.md | 8 + .../components/forms/IconButton.d.ts | 20 + design-system/components/forms/IconButton.jsx | 59 + .../components/forms/IconButton.prompt.md | 9 + design-system/components/forms/Input.d.ts | 24 + design-system/components/forms/Input.jsx | 90 + .../components/forms/Input.prompt.md | 9 + design-system/components/forms/Select.d.ts | 23 + design-system/components/forms/Select.jsx | 76 + .../components/forms/Select.prompt.md | 9 + design-system/components/forms/Switch.d.ts | 15 + design-system/components/forms/Switch.jsx | 48 + .../components/forms/Switch.prompt.md | 7 + .../components/forms/forms.card.html | 64 + .../components/navigation/NavItem.d.ts | 27 + .../components/navigation/NavItem.jsx | 55 + .../components/navigation/NavItem.prompt.md | 9 + design-system/components/navigation/Tabs.d.ts | 21 + design-system/components/navigation/Tabs.jsx | 50 + .../components/navigation/Tabs.prompt.md | 8 + .../navigation/navigation.card.html | 53 + .../ChannelBuilder.jsx | 619 +++ .../design_handoff_channel_builder/README.md | 163 + ...2026-07-01-chicorytv-ui-redesign-design.md | 229 + design-system/guidelines/brand-logo.card.html | 30 + .../guidelines/color-accent.card.html | 20 + .../guidelines/color-status.card.html | 21 + .../guidelines/color-surfaces.card.html | 20 + design-system/guidelines/color-text.card.html | 17 + .../guidelines/color-themes.card.html | 35 + .../guidelines/radius-elevation.card.html | 29 + .../guidelines/spacing-scale.card.html | 25 + design-system/guidelines/type-mono.card.html | 29 + design-system/guidelines/type-sans.card.html | 21 + design-system/readme.md | 190 + design-system/styles.css | 11 + design-system/support.js | 1658 +++++++ .../templates/chicorytv-admin/.thumbnail | Bin 0 -> 1042 bytes .../chicorytv-admin/ChannelBuilder.jsx | 619 +++ .../templates/chicorytv-admin/Channels.jsx | 188 + .../chicorytv-admin/ChicoryTvAdmin.dc.html | 19 + .../templates/chicorytv-admin/Dashboard.jsx | 145 + .../templates/chicorytv-admin/Epg.jsx | 108 + .../templates/chicorytv-admin/Playouts.jsx | 204 + .../chicorytv-admin/ScheduleEditor.jsx | 245 ++ .../chicorytv-admin/ScheduleLibrary.jsx | 117 + .../templates/chicorytv-admin/Shell.jsx | 144 + .../templates/chicorytv-admin/app.html | 142 + .../templates/chicorytv-admin/data.js | 130 + .../templates/chicorytv-admin/ds-base.js | 29 + .../templates/chicorytv-admin/icons.jsx | 18 + .../templates/chicorytv-admin/support.js | 1658 +++++++ design-system/tokens/colors.css | 65 + design-system/tokens/fonts.css | 5 + .../tokens/radius-elevation-motion.css | 27 + design-system/tokens/spacing.css | 35 + design-system/tokens/themes.css | 77 + design-system/tokens/typography.css | 45 + ...2026-07-01-chicorytv-ui-redesign-design.md | 134 + docker/Dockerfile | 12 + ...hicorytv-spa-scaffold-build-integration.md | 63 + web/eslint.config.js | 26 + web/index.html | 12 + web/package-lock.json | 3873 +++++++++++++++++ web/package.json | 36 + web/src/App.test.tsx | 15 + web/src/App.tsx | 86 + web/src/designSystem.ts | 3 + web/src/main.tsx | 11 + web/src/setupTests.ts | 1 + web/src/shell.css | 199 + web/src/vite-env.d.ts | 1 + web/tsconfig.app.json | 26 + web/tsconfig.json | 7 + web/tsconfig.node.json | 15 + web/vite.config.ts | 33 + 125 files changed, 17233 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Tests/StartupSpaHostingTests.cs create mode 100644 design-system/Canvas.dc.html create mode 100644 design-system/SKILL.md create mode 100644 design-system/_adherence.oxlintrc.json create mode 100644 design-system/_ds_bundle.js create mode 100644 design-system/_ds_manifest.json create mode 100644 design-system/assets/chicory-mark.svg create mode 100644 design-system/assets/chicorytv-icon.svg create mode 100644 design-system/assets/chicorytv-wordmark.svg create mode 100644 design-system/components/data-display/Badge.d.ts create mode 100644 design-system/components/data-display/Badge.jsx create mode 100644 design-system/components/data-display/Badge.prompt.md create mode 100644 design-system/components/data-display/Card.d.ts create mode 100644 design-system/components/data-display/Card.jsx create mode 100644 design-system/components/data-display/Card.prompt.md create mode 100644 design-system/components/data-display/ChannelLogo.d.ts create mode 100644 design-system/components/data-display/ChannelLogo.jsx create mode 100644 design-system/components/data-display/ChannelLogo.prompt.md create mode 100644 design-system/components/data-display/ProgressBar.d.ts create mode 100644 design-system/components/data-display/ProgressBar.jsx create mode 100644 design-system/components/data-display/ProgressBar.prompt.md create mode 100644 design-system/components/data-display/Stat.d.ts create mode 100644 design-system/components/data-display/Stat.jsx create mode 100644 design-system/components/data-display/Stat.prompt.md create mode 100644 design-system/components/data-display/StatusDot.d.ts create mode 100644 design-system/components/data-display/StatusDot.jsx create mode 100644 design-system/components/data-display/StatusDot.prompt.md create mode 100644 design-system/components/data-display/Tag.d.ts create mode 100644 design-system/components/data-display/Tag.jsx create mode 100644 design-system/components/data-display/Tag.prompt.md create mode 100644 design-system/components/data-display/data-display.card.html create mode 100644 design-system/components/feedback/Spinner.d.ts create mode 100644 design-system/components/feedback/Spinner.jsx create mode 100644 design-system/components/feedback/Spinner.prompt.md create mode 100644 design-system/components/feedback/Toast.d.ts create mode 100644 design-system/components/feedback/Toast.jsx create mode 100644 design-system/components/feedback/Toast.prompt.md create mode 100644 design-system/components/feedback/Tooltip.d.ts create mode 100644 design-system/components/feedback/Tooltip.jsx create mode 100644 design-system/components/feedback/Tooltip.prompt.md create mode 100644 design-system/components/feedback/feedback.card.html create mode 100644 design-system/components/forms/Button.d.ts create mode 100644 design-system/components/forms/Button.jsx create mode 100644 design-system/components/forms/Button.prompt.md create mode 100644 design-system/components/forms/Checkbox.d.ts create mode 100644 design-system/components/forms/Checkbox.jsx create mode 100644 design-system/components/forms/Checkbox.prompt.md create mode 100644 design-system/components/forms/IconButton.d.ts create mode 100644 design-system/components/forms/IconButton.jsx create mode 100644 design-system/components/forms/IconButton.prompt.md create mode 100644 design-system/components/forms/Input.d.ts create mode 100644 design-system/components/forms/Input.jsx create mode 100644 design-system/components/forms/Input.prompt.md create mode 100644 design-system/components/forms/Select.d.ts create mode 100644 design-system/components/forms/Select.jsx create mode 100644 design-system/components/forms/Select.prompt.md create mode 100644 design-system/components/forms/Switch.d.ts create mode 100644 design-system/components/forms/Switch.jsx create mode 100644 design-system/components/forms/Switch.prompt.md create mode 100644 design-system/components/forms/forms.card.html create mode 100644 design-system/components/navigation/NavItem.d.ts create mode 100644 design-system/components/navigation/NavItem.jsx create mode 100644 design-system/components/navigation/NavItem.prompt.md create mode 100644 design-system/components/navigation/Tabs.d.ts create mode 100644 design-system/components/navigation/Tabs.jsx create mode 100644 design-system/components/navigation/Tabs.prompt.md create mode 100644 design-system/components/navigation/navigation.card.html create mode 100644 design-system/design_handoff_channel_builder/ChannelBuilder.jsx create mode 100644 design-system/design_handoff_channel_builder/README.md create mode 100644 design-system/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md create mode 100644 design-system/guidelines/brand-logo.card.html create mode 100644 design-system/guidelines/color-accent.card.html create mode 100644 design-system/guidelines/color-status.card.html create mode 100644 design-system/guidelines/color-surfaces.card.html create mode 100644 design-system/guidelines/color-text.card.html create mode 100644 design-system/guidelines/color-themes.card.html create mode 100644 design-system/guidelines/radius-elevation.card.html create mode 100644 design-system/guidelines/spacing-scale.card.html create mode 100644 design-system/guidelines/type-mono.card.html create mode 100644 design-system/guidelines/type-sans.card.html create mode 100644 design-system/readme.md create mode 100644 design-system/styles.css create mode 100644 design-system/support.js create mode 100644 design-system/templates/chicorytv-admin/.thumbnail create mode 100644 design-system/templates/chicorytv-admin/ChannelBuilder.jsx create mode 100644 design-system/templates/chicorytv-admin/Channels.jsx create mode 100644 design-system/templates/chicorytv-admin/ChicoryTvAdmin.dc.html create mode 100644 design-system/templates/chicorytv-admin/Dashboard.jsx create mode 100644 design-system/templates/chicorytv-admin/Epg.jsx create mode 100644 design-system/templates/chicorytv-admin/Playouts.jsx create mode 100644 design-system/templates/chicorytv-admin/ScheduleEditor.jsx create mode 100644 design-system/templates/chicorytv-admin/ScheduleLibrary.jsx create mode 100644 design-system/templates/chicorytv-admin/Shell.jsx create mode 100644 design-system/templates/chicorytv-admin/app.html create mode 100644 design-system/templates/chicorytv-admin/data.js create mode 100644 design-system/templates/chicorytv-admin/ds-base.js create mode 100644 design-system/templates/chicorytv-admin/icons.jsx create mode 100644 design-system/templates/chicorytv-admin/support.js create mode 100644 design-system/tokens/colors.css create mode 100644 design-system/tokens/fonts.css create mode 100644 design-system/tokens/radius-elevation-motion.css create mode 100644 design-system/tokens/spacing.css create mode 100644 design-system/tokens/themes.css create mode 100644 design-system/tokens/typography.css create mode 100644 design-system/uploads/2026-07-01-chicorytv-ui-redesign-design.md create mode 100644 docs/superpowers/plans/2026-07-02-chicorytv-spa-scaffold-build-integration.md create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/designSystem.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/setupTests.ts create mode 100644 web/src/shell.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 55157fe10..8ca3a240e 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -50,6 +50,33 @@ jobs: - name: Restore run: dotnet restore + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install SPA dependencies + working-directory: web + run: npm ci + + - name: Lint SPA + working-directory: web + run: npm run lint + + - name: Typecheck SPA + working-directory: web + run: npm run typecheck + + - name: Test SPA + working-directory: web + run: npm test -- --run + + - name: Build SPA + working-directory: web + run: npm run build + - name: Strip Scanner project ref (matches Docker build) run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj diff --git a/.gitignore b/.gitignore index f276affa0..2a77b0fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ scripts/download-test-content.sh docker-compose.override.yml ErsatzTV/wwwroot/v2/ +ErsatzTV/wwwroot/app/ +web/dist/ +web/node_modules/ diff --git a/ErsatzTV.Tests/StartupSpaHostingTests.cs b/ErsatzTV.Tests/StartupSpaHostingTests.cs new file mode 100644 index 000000000..715f861d1 --- /dev/null +++ b/ErsatzTV.Tests/StartupSpaHostingTests.cs @@ -0,0 +1,45 @@ +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests; + +[TestFixture] +public class StartupSpaHostingTests +{ + private static readonly string StartupSource = File.ReadAllText(FindStartupPath()); + + [Test] + public void Startup_Should_Mount_ChicoryTv_Spa_At_App_Path() + { + StartupSource.ShouldContain("ctx.Request.Path.StartsWithSegments(\"/app\")"); + StartupSource.ShouldContain("MapWhen("); + StartupSource.ShouldContain("SpaStaticFileRoot"); + StartupSource.ShouldContain("RequestPath = \"/app\""); + StartupSource.ShouldContain("appIndexFile"); + } + + [Test] + public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback() + { + StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)"); + StartupSource.ShouldContain("bool IsSpaPath(PathString path)"); + } + + private static string FindStartupPath() + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + + while (directory is not null) + { + string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs"); + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 0e333f292..71f265cca 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -681,7 +681,42 @@ public class Startup }); app.MapWhen( - ctx => !IsIptvPath(ctx.Request.Path), + ctx => ctx.Request.Path.StartsWithSegments("/app"), + spa => + { + string spaStaticFileRoot = SpaStaticFileRoot(); + IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot) + ? new PhysicalFileProvider(spaStaticFileRoot) + : new NullFileProvider(); + + spa.UseStaticFiles( + new StaticFileOptions + { + FileProvider = spaFileProvider, + RequestPath = "/app" + }); + + spa.Run( + async context => + { + Microsoft.Extensions.FileProviders.IFileInfo appIndexFile = + spaFileProvider.GetFileInfo("index.html"); + if (!appIndexFile.Exists) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength = appIndexFile.Length; + + await using Stream stream = appIndexFile.CreateReadStream(); + await stream.CopyToAsync(context.Response.Body); + }); + }); + + app.MapWhen( + ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path), blazor => { blazor.UseRouting(); @@ -736,6 +771,16 @@ public class Startup path.StartsWithSegments("/lineup.json") || path.StartsWithSegments("/lineup_status.json"); } + + bool IsSpaPath(PathString path) => path.StartsWithSegments("/app"); + + string SpaStaticFileRoot() + { + string webRootPath = CurrentEnvironment.WebRootPath ?? + Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot"); + + return Path.Combine(webRootPath, "app"); + } } private static void CustomServices(IServiceCollection services) diff --git a/design-system/Canvas.dc.html b/design-system/Canvas.dc.html new file mode 100644 index 000000000..c795a2fcd --- /dev/null +++ b/design-system/Canvas.dc.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/design-system/SKILL.md b/design-system/SKILL.md new file mode 100644 index 000000000..35b7a0d21 --- /dev/null +++ b/design-system/SKILL.md @@ -0,0 +1,17 @@ +--- +name: chicorytv-design +description: Use this skill to generate well-branded interfaces and assets for ChicoryTV, either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping. +user-invocable: true +--- + +Read the README.md file within this skill, and explore the other available files. +If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. +If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. + +Quick map: +- `readme.md` — brand context, content & visual foundations, iconography, file index. +- `styles.css` — the single entry point; link it and use the CSS custom properties. +- `tokens/` — color, type, spacing, radius/elevation/motion tokens + font loading. +- `components/` — React primitives (forms, data-display, feedback, navigation). +- `templates/chicorytv-admin/` — full admin shell to start from / copy screens out of (incl. the library-to-lineup Channel Builder). +- `assets/` — chicory-flower mark, wordmark, app icon. diff --git a/design-system/_adherence.oxlintrc.json b/design-system/_adherence.oxlintrc.json new file mode 100644 index 000000000..9339acdb7 --- /dev/null +++ b/design-system/_adherence.oxlintrc.json @@ -0,0 +1,470 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/data-display/**", + "components/feedback/**", + "components/forms/**", + "components/navigation/**", + "design_handoff_channel_builder/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Geist|Geist Mono))/i]", + "message": "Font not provided by the design system. Available: Geist, Geist Mono." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|solid|dot|style|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: children, tone, solid, dot, style." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent|ok|warn|error)$/]", + "message": " tone must be one of 'neutral' | 'accent' | 'ok' | 'warn' | 'error'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|startIcon|endIcon|disabled|loading|fullWidth|type|onClick|style|key|ref|className|style|children)$/]", + "message": " + ); +} diff --git a/design-system/components/navigation/Tabs.prompt.md b/design-system/components/navigation/Tabs.prompt.md new file mode 100644 index 000000000..f7298dbab --- /dev/null +++ b/design-system/components/navigation/Tabs.prompt.md @@ -0,0 +1,8 @@ +**Tabs** — underlined tab bar for editor sub-views and detail panes (Channel → General / Streaming / Watermark; Playout → Detail / Log). Active tab shows an accent underline. + +```jsx + +``` + +Props: `tabs` (string | {value,label,icon,count}), `value`, `onChange`. diff --git a/design-system/components/navigation/navigation.card.html b/design-system/components/navigation/navigation.card.html new file mode 100644 index 000000000..dbf54771c --- /dev/null +++ b/design-system/components/navigation/navigation.card.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + +
+ + + diff --git a/design-system/design_handoff_channel_builder/ChannelBuilder.jsx b/design-system/design_handoff_channel_builder/ChannelBuilder.jsx new file mode 100644 index 000000000..452c63399 --- /dev/null +++ b/design-system/design_handoff_channel_builder/ChannelBuilder.jsx @@ -0,0 +1,619 @@ +// Channel Builder — GROUND-UP replacement for the four-editor create chain +// (Channel -> Schedule -> Schedule Items -> Playout). One library-to-lineup screen. +// Left: searchable library browser. Center: the channel lineup (add by double-click +// or drag; drag to reorder). Right: friendly options + Channel Template + Advanced. +// Behind the scenes "Create" builds Channel + Collection + Schedule + Items + Playout. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Input, Select, Switch, Badge, ChannelLogo, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" }; + + // ---- Mock library (shows / movies / music / docs across libraries) -------- + const LIB = [ + { id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96 }, + { id: "s2", title: "Tom & Jerry", type: "Show", library: "Cartoons", sub: "164 shorts", min: 72 }, + { id: "s3", title: "Popeye", type: "Show", library: "Cartoons", sub: "108 shorts", min: 54 }, + { id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84 }, + { id: "s5", title: "Scooby-Doo", type: "Show", library: "Cartoons", sub: "97 episodes", min: 60 }, + { id: "s6", title: "Pink Panther", type: "Show", library: "Cartoons", sub: "124 shorts", min: 48 }, + { id: "m1", title: "Blade Runner", type: "Movie", library: "Movies", sub: "1982 · Sci-Fi", min: 117 }, + { id: "m2", title: "Alien", type: "Movie", library: "Movies", sub: "1979 · Horror", min: 117 }, + { id: "m3", title: "The Thing", type: "Movie", library: "Movies", sub: "1982 · Horror", min: 109 }, + { id: "m4", title: "Metropolis", type: "Movie", library: "Movies", sub: "1927 · Sci-Fi", min: 153 }, + { id: "m5", title: "Nosferatu", type: "Movie", library: "Movies", sub: "1922 · Horror", min: 94 }, + { id: "t1", title: "The Twilight Zone", type: "Show", library: "TV Shows", sub: "156 episodes", min: 51 }, + { id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50 }, + { id: "t3", title: "Columbo", type: "Show", library: "TV Shows", sub: "69 episodes", min: 94 }, + { id: "t4", title: "Cheers", type: "Show", library: "TV Shows", sub: "275 episodes", min: 24 }, + { id: "u1", title: "80s Synthpop", type: "Music", library: "Music Videos", sub: "212 videos", min: 45 }, + { id: "u2", title: "90s Alt Rock", type: "Music", library: "Music Videos", sub: "188 videos", min: 45 }, + { id: "u3", title: "Chart Countdown", type: "Music", library: "Music Videos", sub: "Top 40", min: 60 }, + { id: "d1", title: "Blue Planet", type: "Doc", library: "Documentaries", sub: "8 episodes", min: 50 }, + { id: "d2", title: "Planet Earth", type: "Doc", library: "Documentaries", sub: "11 episodes", min: 50 }, + { id: "d3", title: "Cosmos", type: "Doc", library: "Documentaries", sub: "13 episodes", min: 60 }, + ]; + const LIBRARIES = ["All", "Cartoons", "Movies", "TV Shows", "Music Videos", "Documentaries"]; + const TYPE_ICON = { Show: "Tv", Movie: "Film", Music: "Music", Doc: "Radio", Multi: "Layers", Smart: "Sparkles", Manual: "FolderTree", Rerun: "Repeat" }; + + // Collections — curated groups (Manual / Smart / Multi / Rerun) addable as a + // single lineup entry, so the builder isn't limited to raw library items. + const COLLECTIONS = [ + { id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true }, + { id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true }, + { id: "c3", title: "Retro Station IDs", type: "Manual", library: "Collection", sub: "Manual · 24 items", min: 12, coll: true }, + { id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly",min: 120, coll: true }, + { id: "c5", title: "Halloween Marathon", type: "Manual", library: "Collection", sub: "Manual · 9 films", min: 900, coll: true }, + ]; + + // Channel Templates — bundle the clunky technical config; one is pre-selected. + const TEMPLATES = [ + { id: "std", name: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, + sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] }, + { id: "music", name: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, + sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] }, + { id: "film", name: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, + sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] }, + { id: "retro", name: "Retro Saturday AM", builtin: false, desc: "Your saved cartoon-block template.", shuffle: true, always: true, + sets: ["1080p H.264", "HLS Segmenter", "Shuffle", "Always on", "Bumpers + ads"] }, + ]; + + // Advanced (~30-field) override set, grouped. Values shown as template-inherited. + const ADV = [ + { group: "Streaming", fields: [ + ["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920×1080"], + ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"], + ]}, + { group: "Filler", fields: [ + ["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], + ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"], + ]}, + { group: "Playback", fields: [ + ["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], + ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"], + ]}, + { group: "Behavior", fields: [ + ["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], + ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"], + ]}, + ]; + + // deterministic low-chroma poster hue from a title + function hueOf(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; } + + function fmtDur(totalMin) { + const h = Math.floor(totalMin / 60), m = totalMin % 60; + if (h && m) return h + "h " + String(m).padStart(2, "0") + "m"; + if (h) return h + "h"; + return m + "m"; + } + + // ---- Overlay scroll region — hides native bar (no reflow when it appears), + // shows a subtle thumb only while scrolling, fading out when idle. ------ + function ScrollArea({ children, innerStyle, scrollerRef }) { + const local = React.useRef(null); + const timer = React.useRef(null); + const [thumb, setThumb] = React.useState(null); + const setRef = (n) => { local.current = n; if (scrollerRef) scrollerRef.current = n; }; + const update = () => { + const el = local.current; if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + if (scrollHeight <= clientHeight + 2) { setThumb(null); return; } + const track = clientHeight - 8; + const h = Math.max(28, track * clientHeight / scrollHeight); + const top = 4 + (track - h) * (scrollTop / (scrollHeight - clientHeight)); + setThumb({ top, h }); + clearTimeout(timer.current); + timer.current = setTimeout(() => setThumb((t) => (t ? { ...t, hidden: true } : null)), 850); + }; + React.useEffect(() => { const id = setTimeout(update, 60); return () => { clearTimeout(id); clearTimeout(timer.current); }; }, []); + return ( +
+
+ {children} +
+ {thumb && ( +
+ )} +
+ ); + } + + // ---- Segmented control — iOS-style sliding active highlight --------------- + function Segmented({ value, options, onChange, style }) { + const idx = Math.max(0, options.findIndex((o) => o.value === value)); + return ( +
+
+ {options.map((o) => { + const on = o.value === value; + return ( + + ); + })} +
+ ); + } + + // ---- Poster placeholder (striped, tinted, mono meta) ---------------------- + function Poster({ item, w, h, mini }) { + const hue = hueOf(item.title); + return ( +
+
+
+ +
+ {!mini && ( +
+
{item.title}
+
{item.sub}
+
+ )} +
+ ); + } + + // ---- Library card ---------------------------------------------------------- + function LibCard({ item, added, compact, onAdd, onDragStart, onDragEnd }) { + if (compact) { + return ( +
+ +
+
{item.title}
+
{item.type} · {item.sub}
+
+ + + +
+ ); + } + return ( +
+
+ + {added && ( +
+ +
+ )} +
+
+ ); + } + + // ---- Lineup row ------------------------------------------------------------ + function LineupRow({ item, index, shuffle, dragging, isOver, onRemove, dragProps }) { + return ( +
+ + + {shuffle + ? + : {index + 1}} + + +
+
{item.title}
+
{item.type} · {item.library}
+
+ {fmtDur(item.min)} + +
+ ); + } + + // ---- Channel image dropzone + live logo/bug preview ----------------------- + function ImagePanel({ logoSrc, setLogoSrc, name }) { + const [sep, setSep] = React.useState(false); + const [over, setOver] = React.useState(false); + const fileRef = React.useRef(null); + const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => setLogoSrc(r.result); r.readAsDataURL(file); }; + return ( +
+
{ e.preventDefault(); setOver(true); }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }} + onClick={() => fileRef.current && fileRef.current.click()} + style={{ display: "flex", gap: 12, padding: 12, borderRadius: "var(--radius-sm)", cursor: "pointer", + background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}> + +
+
{logoSrc ? "Channel image set" : "Drop channel image"}
+
Used as both the guide logo and the on-screen bug.
+
+ read(e.target.files && e.target.files[0])} /> +
+ + {/* live preview: listing logo + on-screen bug */} +
+
+
Guide logo
+
+ +
+
{name || "New Channel"}
+
listing
+
+
+
+
+
On-screen bug
+
+
+ +
+
+
+
+ + + {sep && ( +
+ + +
+ )} +
+ ); + } + + // ---- Advanced disclosure — one-click Override (edit) or View defaults ------ + function Advanced({ templateName, scrollerRef }) { + const [mode, setMode] = React.useState("closed"); // closed | view | override + const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }); + const override = mode === "override"; + const count = ADV.reduce((n, g) => n + g.fields.length, 0); + const fieldsRef = React.useRef(null); + const rootRef = React.useRef(null); + const [maxH, setMaxH] = React.useState(0); + React.useLayoutEffect(() => { + if (mode === "closed") setMaxH(0); + else if (fieldsRef.current) setMaxH(fieldsRef.current.scrollHeight); + }, [mode]); + const toggle = (m) => { + const next = mode === m ? "closed" : m; + setMode(next); + if (next !== "closed" && scrollerRef && scrollerRef.current && rootRef.current) { + const sc = scrollerRef.current; + requestAnimationFrame(() => { + const target = sc.scrollTop + rootRef.current.getBoundingClientRect().top - sc.getBoundingClientRect().top - 20; + sc.scrollTo({ top: Math.max(0, target), behavior: "smooth" }); + }); + } + }; + const tabBtn = (active) => ({ + display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", + borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", + background: active ? "color-mix(in srgb, var(--action-primary) 14%, transparent)" : "var(--ctv-surface-2)", + color: active ? "var(--action-primary)" : "var(--text-secondary)", + border: `1px solid ${active ? "color-mix(in srgb, var(--action-primary) 38%, transparent)" : "var(--border-hairline)"}`, + transition: "background 200ms cubic-bezier(0.16,1,0.3,1), color 200ms cubic-bezier(0.16,1,0.3,1), border-color 200ms cubic-bezier(0.16,1,0.3,1)", + }); + return ( +
+
+ + Advanced + + {count} fields · {templateName} +
+
+ + +
+
+
+
+ + {override ? "Editing these overrides the template for this channel only." : `Read-only — inherited from the ${templateName} template. Turn on Override to edit.`} +
+ {ADV.map((g) => ( +
+
{g.group}
+ {override ? ( +
+ {g.fields.map(([k]) => ( + setVals((s) => ({ ...s, [k]: e.target.value }))} /> + ))} +
+ ) : ( +
+ {g.fields.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ )} +
+ ))} +
+
+
+ ); + } + + // ---- Main ------------------------------------------------------------------ + function ChannelBuilder() { + const [q, setQ] = React.useState(""); + const [libFilter, setLibFilter] = React.useState("All"); + const [compact, setCompact] = React.useState(false); + const [lineup, setLineup] = React.useState(() => ["s1", "s2", "s3", "s4"].map((id) => LIB.find((x) => x.id === id))); + const [name, setName] = React.useState("Retro Cartoons"); + const [number, setNumber] = React.useState("13.1"); + const [shuffle, setShuffle] = React.useState(false); + const [always, setAlways] = React.useState(true); + const [logoSrc, setLogoSrc] = React.useState(null); + const [tpl, setTpl] = React.useState("std"); + const [tplOpen, setTplOpen] = React.useState(false); + const [source, setSource] = React.useState("library"); // library | collections + + // drag state + const [dragLib, setDragLib] = React.useState(null); // library item id being dragged + const [dragRow, setDragRow] = React.useState(null); // lineup index being dragged + const [overRow, setOverRow] = React.useState(null); + const railScrollerRef = React.useRef(null); + + const addedIds = new Set(lineup.map((i) => i.id)); + const pool = source === "collections" ? COLLECTIONS : LIB; + const filtered = pool.filter((i) => + (source === "collections" || libFilter === "All" || i.library === libFilter) && + (!q || i.title.toLowerCase().includes(q.toLowerCase()))); + + const add = (item) => setLineup((l) => (l.some((x) => x.id === item.id) ? l : [...l, item])); + const removeAt = (idx) => setLineup((l) => l.filter((_, i) => i !== idx)); + const totalMin = lineup.reduce((n, i) => n + i.min, 0); + const template = TEMPLATES.find((t) => t.id === tpl); + const canCreate = name.trim() && lineup.length > 0; + + // reorder / insert on drop at target index + const dropAt = (targetIdx) => { + if (dragRow != null) { + setLineup((l) => { const n = l.slice(); const [m] = n.splice(dragRow, 1); n.splice(targetIdx, 0, m); return n; }); + } else if (dragLib != null) { + const item = [...LIB, ...COLLECTIONS].find((x) => x.id === dragLib); + if (item && !addedIds.has(item.id)) setLineup((l) => { const n = l.slice(); n.splice(targetIdx, 0, item); return n; }); + } + setDragLib(null); setDragRow(null); setOverRow(null); + }; + const dropEnd = () => { + if (dragLib != null) { const item = [...LIB, ...COLLECTIONS].find((x) => x.id === dragLib); if (item) add(item); } + setDragLib(null); setDragRow(null); setOverRow(null); + }; + + return ( +
+ {/* builder toolbar */} +
+ +
+
New Channel
+
Build from your library — {lineup.length} in lineup · {fmtDur(totalMin)}
+
+ + + + +
+ + {/* body — library | lineup | settings */} +
+ {/* LEFT — library browser */} +
+
+
+ + + setCompact(v === "list")} options={[{ value: "grid", icon: "LayoutGrid", title: "Poster grid" }, { value: "list", icon: "Rows3", title: "Compact list" }]} /> +
+ setQ(e.target.value)} leadingIcon={} placeholder={source === "collections" ? "Search collections\u2026" : "Search shows & movies\u2026"} /> + {source === "library" && ( +
+ {LIBRARIES.map((l) => { + const on = libFilter === l; + return ( + + ); + })} +
+ )} +
+ + {filtered.length === 0 ? ( +
No titles match.
+ ) : compact ? ( +
+ {filtered.map((item) => ( + add(item)} + onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} /> + ))} +
+ ) : ( +
+ {filtered.map((item) => ( + add(item)} + onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} /> + ))} +
+ )} +
+
+ + {/* CENTER — lineup */} +
{ if (dragLib != null) e.preventDefault(); }} + onDrop={() => { if (dragLib != null) dropEnd(); }}> +
+ Lineup + {shuffle ? "Shuffled" : "Sequential"} + + {lineup.length > 0 && } +
+ + {lineup.length === 0 ? ( +
+ +
This channel has no content yet. Double-click or drag titles from the library to build the lineup.
+
+ ) : ( +
+ {lineup.map((item, idx) => ( + removeAt(idx)} + dragProps={{ + draggable: true, + onDragStart: () => setDragRow(idx), + onDragEnd: () => { setDragRow(null); setOverRow(null); }, + onDragOver: (e) => { e.preventDefault(); setOverRow(idx); }, + onDrop: (e) => { e.stopPropagation(); dropAt(idx); }, + }} /> + ))} +
+ Double-click or drag more titles to add +
+
+ )} +
+
+ + {/* RIGHT — friendly options + template + advanced */} + +
+
+ ); + } + + window.CTVChannelBuilder = ChannelBuilder; +})(); diff --git a/design-system/design_handoff_channel_builder/README.md b/design-system/design_handoff_channel_builder/README.md new file mode 100644 index 000000000..bf3988888 --- /dev/null +++ b/design-system/design_handoff_channel_builder/README.md @@ -0,0 +1,163 @@ +# Handoff: ChicoryTV — Channel Builder (+ admin design system) + +## Overview +The **Channel Builder** ("New Channel") is a ground-up replacement for ErsatzTV's four-editor +channel-creation chain (Channel → Schedule → Schedule Items → Playout). It is **one screen**: browse +your media library on the left, build the channel's lineup in the center, set friendly options + +a Channel Template on the right, and hit **Create Channel** — which (in the real app) builds all the +underlying entities atomically. It ships inside the **ChicoryTV admin**, a dark, dense, app-like +console and its React component library. + +## About the design files +The files in this bundle are **design references created in HTML/React** — prototypes showing the +intended look and behavior, **not production code to ship directly**. The task is to **recreate these +designs in the target codebase's environment** using its established patterns. Per the redesign brief, +the intended target is a **React SPA over the ErsatzTV REST API** (replacing Blazor/MudBlazor); if that +app doesn't exist yet, scaffold it (Vite + React + TypeScript is a reasonable default) and implement +there. Nothing here depends on Blazor. + +The prototypes are built on a real design system (React primitives + CSS-custom-property tokens + +three themes) that lives in the parent project — treat that as the component/token source of truth. + +## Fidelity +**High-fidelity.** Final colors, typography, spacing, radii, motion, and interactions are all resolved. +Recreate pixel-for-pixel using the codebase's real component library (promoting these primitives into +it, or mapping to existing equivalents). All values below are exact. + +--- + +## Screens / Views + +### New Channel — Channel Builder (primary deliverable) +**Purpose:** create a working channel by adding library items to a lineup and setting a few friendly +options; technical config comes from a selectable Channel Template. + +**Layout:** full-height column. A **builder toolbar** (fixed, 12px×20px padding, hairline bottom +border): accent icon tile + "New Channel" title + live summary (`N in lineup · total duration`) on the +left; **Cancel** (ghost) and **Create Channel** (primary, right) — Create is disabled until there's a +name + ≥1 lineup item, tooltip below. Below the toolbar, a 3-column grid, full height: +`grid-template-columns: minmax(0,1.2fr) minmax(0,1fr) 304px` (columns shrink, never overflow; the +304px rail is fixed). Each column scrolls independently. + +**Column 1 — Library browser** (`overflow:hidden` section): +- Header: a **Segmented** source toggle **Library / Collections** (sliding highlight), a spacer, and a + grid/compact **density** Segmented. Then a search input (`Search shows & movies…` / + `Search collections…`). Then, in Library mode only, pill **filter chips** (All, Cartoons, Movies, + TV Shows, Music Videos, Documentaries) — active chip uses accent-soft bg + accent text. +- Body (scrolls): **poster grid** `repeat(auto-fill, minmax(104px,1fr))`, gap 10 — each poster is a + 2:3 tinted, diagonally-striped placeholder (deterministic hue from title) with a bottom gradient + scrim, title (12px semibold) + mono meta, and a type icon top-left (Tv/Film/Music/Radio, or + Layers/Sparkles/FolderTree/Repeat for collection kinds). Compact mode is a 30×40 thumb + title + + `type · meta` row. **Double-click or drag** to add; added cards show an accent ring + a **pop-in** + check badge (top-right). + +**Column 2 — Lineup** (the channel's content, `overflow:hidden` section, accepts drops): +- Header: "LINEUP" eyebrow, a **Sequential / Shuffled** status badge (icon, informational — the real + control is the Shuffle toggle in the rail), a spacer, a **Clear** ghost button. +- Body (scrolls): ordered rows, gap 7. Each row: grip handle, order number (mono) when Sequential or a + shuffle glyph when Shuffled, a 34×46 poster thumb, title (14px semibold) + `type · library`, mono + duration, and a remove (×) button. **Drag-to-reorder** (HTML5 DnD; a `0 -2px 0 accent` top-border + marks the drop target; dragged row drops to 0.35 opacity). Empty state = dashed dropzone. A trailing + dashed "double-click or drag more titles to add" hint. + +**Column 3 — Settings rail** (fixed 304px, `background: surface-card`, scrolls via an overlay +scrollbar — hidden native bar so no reflow, subtle fading thumb): +- **Channel name** input; **Number** input with an `AUTO` mono chip trailing. +- **Channel image**: a dashed dropzone (drop or click → file input; live `FileReader` preview). Below, + two preview tiles — **Guide logo** (logo + name + "listing") and **On-screen bug** (logo bottom-right + over a striped "video frame"). A "Set logo & bug separately" disclosure reveals two image buttons. + Default: the one image drives both. +- **Shuffle** toggle (label + "Plays in a random order." / "Plays in sequence (chronological)."). Shows + an "overrides template" tag when it differs from the selected template's default. +- **Always playing** toggle — the live-TV concept. Rendered in the **live color** (`--ctv-live`) with a + tinted row + a "● LIVE" pill when on. Helper: "Like live TV — advances on schedule even when nobody is + watching." Also shows the "overrides template" tag when diverging. *(Backend gap #68 — still needs + confirmation.)* +- **Channel Template** picker: a button showing the current template (name + one-line desc + Custom + badge). Opens a list of built-ins + custom, each with a radio, name, desc; a "Save current settings as + template…" action. Selecting a template **applies its Shuffle + Always-playing defaults** and shows + its config as chips (e.g. `1080p H.264`, `HLS Segmenter`, `Sequential`, `Always on`, `Pre + post filler`). +- **Advanced** disclosure: a **SlidersHorizontal** header + `N fields ·