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