diff --git a/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs index fd404fa5d..7aa6a1c14 100644 --- a/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs @@ -1,8 +1,5 @@ -using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; -using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; namespace ErsatzTV.Application.Troubleshooting.Queries; @@ -15,83 +12,10 @@ public class DecodePlayoutHistoryHandler(IDbContextFactory dbContextF { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var decodedKey = JsonConvert.DeserializeObject(request.PlayoutHistory.Key); - - PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None; - CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection; - - string name = string.Empty; - - switch (collectionType) - { - case CollectionType.Collection: - name = await dbContext.Collections - .AsNoTracking() - .Where(c => c.Id == (decodedKey.CollectionId ?? 0)) - .Map(c => c.Name) - .FirstOrDefaultAsync(cancellationToken); - break; - case CollectionType.SmartCollection: - name = await dbContext.SmartCollections - .AsNoTracking() - .Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0)) - .Map(c => c.Name) - .FirstOrDefaultAsync(cancellationToken); - break; - } - - string mediaItemType = string.Empty; - string mediaItemTitle = string.Empty; - - Details details = JsonConvert.DeserializeObject
(request.PlayoutHistory.Details); - if (details?.MediaItemId != null) - { - Option maybeMediaItem = await dbContext.MediaItems - .AsNoTracking() - .Include(i => i.LibraryPath) - .ThenInclude(lp => lp.Library) - .ThenInclude(l => l.MediaSource) - .Include(i => (i as Movie).MovieMetadata) - .Include(i => (i as Episode).EpisodeMetadata) - .Include(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .Include(i => (i as OtherVideo).OtherVideoMetadata) - .Include(i => (i as Image).ImageMetadata) - .Include(i => (i as RemoteStream).RemoteStreamMetadata) - .Include(i => (i as Song).SongMetadata) - .Include(i => (i as MusicVideo).MusicVideoMetadata) - .Include(i => (i as MusicVideo).Artist) - .ThenInclude(a => a.ArtistMetadata) - .SelectOneAsync(i => i.Id, i => i.Id == details.MediaItemId, cancellationToken); - - foreach (var mediaItem in maybeMediaItem) - { - mediaItemType = mediaItem switch - { - Episode => "Episode", - Movie => "Movie", - MusicVideo => "Music Video", - OtherVideo => "Other Video", - Song => "Song", - Image => "Image", - RemoteStream => "Remote Stream", - _ => $"Unknown ({mediaItem.GetType().Name})" - }; - - mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option.None); - } - } - - return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle); + return await PlayoutHistoryDecoder.Decode( + dbContext, + request.PlayoutHistory.Key, + request.PlayoutHistory.Details, + cancellationToken); } - - private sealed record BlockItemHistoryKey( - int? BlockId, - PlaybackOrder? PlaybackOrder, - CollectionType? CollectionType, - int? CollectionId, - int? SmartCollectionId); - - private sealed record Details(int? MediaItemId); } diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetails.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetails.cs new file mode 100644 index 000000000..ac63a30e9 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetails.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public record GetPlayoutHistoryDetails(int Id) : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetailsHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetailsHandler.cs new file mode 100644 index 000000000..e2eee4345 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetailsHandler.cs @@ -0,0 +1,42 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Errors; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public class GetPlayoutHistoryDetailsHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetPlayoutHistoryDetails request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybeHistory = await dbContext.PlayoutHistory + .AsNoTracking() + .SelectOneAsync(ph => ph.Id, ph => ph.Id == request.Id, cancellationToken); + + foreach (PlayoutHistory history in maybeHistory) + { + try + { + return await PlayoutHistoryDecoder.Decode( + dbContext, + history.Key, + history.Details, + cancellationToken); + } + catch (Exception ex) + { + // old/corrupt rows may carry malformed Key/Details JSON; surface a 422 rather than a 500 + return BaseError.New($"Unable to decode playout history: {ex.Message}"); + } + } + + return new NotFoundError($"Playout history {request.Id} does not exist"); + } +} diff --git a/ErsatzTV.Application/Troubleshooting/Queries/PlayoutHistoryDecoder.cs b/ErsatzTV.Application/Troubleshooting/Queries/PlayoutHistoryDecoder.cs new file mode 100644 index 000000000..e59759270 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/PlayoutHistoryDecoder.cs @@ -0,0 +1,99 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +// Shared decode logic for a single PlayoutHistory row's Key/Details JSON. Both +// DecodePlayoutHistoryHandler (Blazor) and GetPlayoutHistoryDetailsHandler (API) use this +// so the collection/media-item lookup lives in exactly one place. +internal static class PlayoutHistoryDecoder +{ + public static async Task Decode( + TvContext dbContext, + string key, + string details, + CancellationToken cancellationToken) + { + var decodedKey = JsonConvert.DeserializeObject(key); + + PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None; + CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection; + + string name = string.Empty; + + switch (collectionType) + { + case CollectionType.Collection: + name = await dbContext.Collections + .AsNoTracking() + .Where(c => c.Id == (decodedKey.CollectionId ?? 0)) + .Map(c => c.Name) + .FirstOrDefaultAsync(cancellationToken); + break; + case CollectionType.SmartCollection: + name = await dbContext.SmartCollections + .AsNoTracking() + .Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0)) + .Map(c => c.Name) + .FirstOrDefaultAsync(cancellationToken); + break; + } + + string mediaItemType = string.Empty; + string mediaItemTitle = string.Empty; + + Details decodedDetails = JsonConvert.DeserializeObject
(details); + if (decodedDetails?.MediaItemId != null) + { + Option maybeMediaItem = await dbContext.MediaItems + .AsNoTracking() + .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) + .ThenInclude(l => l.MediaSource) + .Include(i => (i as Movie).MovieMetadata) + .Include(i => (i as Episode).EpisodeMetadata) + .Include(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .Include(i => (i as OtherVideo).OtherVideoMetadata) + .Include(i => (i as Image).ImageMetadata) + .Include(i => (i as RemoteStream).RemoteStreamMetadata) + .Include(i => (i as Song).SongMetadata) + .Include(i => (i as MusicVideo).MusicVideoMetadata) + .Include(i => (i as MusicVideo).Artist) + .ThenInclude(a => a.ArtistMetadata) + .SelectOneAsync(i => i.Id, i => i.Id == decodedDetails.MediaItemId, cancellationToken); + + foreach (var mediaItem in maybeMediaItem) + { + mediaItemType = mediaItem switch + { + Episode => "Episode", + Movie => "Movie", + MusicVideo => "Music Video", + OtherVideo => "Other Video", + Song => "Song", + Image => "Image", + RemoteStream => "Remote Stream", + _ => $"Unknown ({mediaItem.GetType().Name})" + }; + + mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option.None); + } + } + + return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle); + } + + private sealed record BlockItemHistoryKey( + int? BlockId, + PlaybackOrder? PlaybackOrder, + CollectionType? CollectionType, + int? CollectionId, + int? SmartCollectionId); + + private sealed record Details(int? MediaItemId); +} diff --git a/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialSchedule.cs b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialSchedule.cs new file mode 100644 index 000000000..65e48b048 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialSchedule.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public record ValidateSequentialSchedule(string Yaml, bool IsImport) : IRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialScheduleHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialScheduleHandler.cs new file mode 100644 index 000000000..fcd70ca7b --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialScheduleHandler.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core.Interfaces.Scheduling; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public class ValidateSequentialScheduleHandler(ISequentialScheduleValidator validator) + : IRequestHandler +{ + public async Task Handle( + ValidateSequentialSchedule request, + CancellationToken cancellationToken) + { + try + { + // ToJson runs first and can throw on malformed YAML (GetValidationMessages catches its + // own exceptions internally); mirrors the Blazor YamlValidator ordering. + string json = validator.ToJson(request.Yaml); + IList messages = await validator.GetValidationMessages(request.Yaml, request.IsImport); + return new ValidateSequentialScheduleViewModel(messages.Count == 0, messages.ToList(), json); + } + catch (Exception ex) + { + return new ValidateSequentialScheduleViewModel(false, [ex.Message], string.Empty); + } + } +} diff --git a/ErsatzTV.Application/Troubleshooting/ValidateSequentialScheduleViewModel.cs b/ErsatzTV.Application/Troubleshooting/ValidateSequentialScheduleViewModel.cs new file mode 100644 index 000000000..dd9574008 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/ValidateSequentialScheduleViewModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Troubleshooting; + +public record ValidateSequentialScheduleViewModel(bool IsValid, List Messages, string Json); diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutHistoryResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutHistoryResponseModel.cs new file mode 100644 index 000000000..17b57221b --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutHistoryResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Playouts; + +public record PagedPlayoutHistoryResponseModel(int TotalCount, List Page); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutHistoryDetailsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryDetailsResponseModel.cs new file mode 100644 index 000000000..e7477bb4f --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryDetailsResponseModel.cs @@ -0,0 +1,11 @@ +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutHistoryDetailsResponseModel( + PlaybackOrder PlaybackOrder, + CollectionType CollectionType, + string Name, + string MediaItemType, + string MediaItemTitle); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutHistoryResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryResponseModel.cs new file mode 100644 index 000000000..27cc88777 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryResponseModel.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutHistoryResponseModel( + int Id, + DateTimeOffset When, + DateTimeOffset Finish, + string Key, + string Details); diff --git a/ErsatzTV.Core/Api/Troubleshooting/ValidateSequentialScheduleResponseModel.cs b/ErsatzTV.Core/Api/Troubleshooting/ValidateSequentialScheduleResponseModel.cs new file mode 100644 index 000000000..14795eb40 --- /dev/null +++ b/ErsatzTV.Core/Api/Troubleshooting/ValidateSequentialScheduleResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Troubleshooting; + +public record ValidateSequentialScheduleResponseModel(bool IsValid, List Messages, string Json); diff --git a/ErsatzTV.Tests/Application/Troubleshooting/GetPlayoutHistoryDetailsHandlerTests.cs b/ErsatzTV.Tests/Application/Troubleshooting/GetPlayoutHistoryDetailsHandlerTests.cs new file mode 100644 index 000000000..26fbd9b5f --- /dev/null +++ b/ErsatzTV.Tests/Application/Troubleshooting/GetPlayoutHistoryDetailsHandlerTests.cs @@ -0,0 +1,104 @@ +using ErsatzTV.Application.Troubleshooting; +using ErsatzTV.Application.Troubleshooting.Queries; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Errors; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Troubleshooting; + +[TestFixture] +public class GetPlayoutHistoryDetailsHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Should_Return_NotFoundError_When_Row_Missing() + { + var handler = new GetPlayoutHistoryDetailsHandler(_db.Factory); + + Either result = + await handler.Handle(new GetPlayoutHistoryDetails(999), CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Should_Decode_Collection_Key_When_Row_Exists() + { + int historyId = await SeedHistory( + """{"CollectionType":"Collection","CollectionId":1,"PlaybackOrder":"Shuffle"}""", + "{}", + seedCollection: true); + + var handler = new GetPlayoutHistoryDetailsHandler(_db.Factory); + + Either result = + await handler.Handle(new GetPlayoutHistoryDetails(historyId), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + PlayoutHistoryDetailsViewModel vm = RightOf(result); + vm.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle); + vm.CollectionType.ShouldBe(CollectionType.Collection); + vm.Name.ShouldBe("Cartoons"); + } + + [Test] + public async Task Should_Return_Error_When_Key_Json_Is_Malformed() + { + int historyId = await SeedHistory("this is not json", "{}", seedCollection: false); + + var handler = new GetPlayoutHistoryDetailsHandler(_db.Factory); + + Either result = + await handler.Handle(new GetPlayoutHistoryDetails(historyId), CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + LeftOf(result).ShouldNotBeOfType(); + } + + private async Task SeedHistory(string key, string details, bool seedCollection) + { + await using TvContext context = _db.CreateContext(); + + if (seedCollection) + { + context.Collections.Add(new Collection { Id = 1, Name = "Cartoons" }); + } + + var history = new PlayoutHistory + { + PlayoutId = 1, + BlockId = 10, + PlaybackOrder = PlaybackOrder.Shuffle, + Index = 0, + Key = key, + ChildKey = string.Empty, + IsCurrentChild = false, + When = DateTime.UtcNow, + Finish = DateTime.UtcNow.AddHours(1), + Details = details + }; + context.PlayoutHistory.Add(history); + await context.SaveChangesAsync(); + return history.Id; + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected Left")); + + private static PlayoutHistoryDetailsViewModel RightOf(Either either) => + either.Match(Left: _ => throw new AssertionException("Expected Right"), Right: v => v); +} diff --git a/ErsatzTV.Tests/Application/Troubleshooting/ValidateSequentialScheduleHandlerTests.cs b/ErsatzTV.Tests/Application/Troubleshooting/ValidateSequentialScheduleHandlerTests.cs new file mode 100644 index 000000000..fa2930e69 --- /dev/null +++ b/ErsatzTV.Tests/Application/Troubleshooting/ValidateSequentialScheduleHandlerTests.cs @@ -0,0 +1,65 @@ +using ErsatzTV.Application.Troubleshooting; +using ErsatzTV.Application.Troubleshooting.Queries; +using ErsatzTV.Core.Interfaces.Scheduling; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Troubleshooting; + +[TestFixture] +public class ValidateSequentialScheduleHandlerTests +{ + private ISequentialScheduleValidator _validator = null!; + private ValidateSequentialScheduleHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _validator = Substitute.For(); + _handler = new ValidateSequentialScheduleHandler(_validator); + } + + [Test] + public async Task Should_Report_Valid_When_No_Messages() + { + _validator.ToJson(Arg.Any()).Returns("{ }"); + _validator.GetValidationMessages(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>([])); + + ValidateSequentialScheduleViewModel result = + await _handler.Handle(new ValidateSequentialSchedule("content: []", false), CancellationToken.None); + + result.IsValid.ShouldBeTrue(); + result.Messages.ShouldBeEmpty(); + result.Json.ShouldBe("{ }"); + } + + [Test] + public async Task Should_Report_Invalid_With_Messages() + { + _validator.ToJson(Arg.Any()).Returns("{ }"); + _validator.GetValidationMessages(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(["content is required"])); + + ValidateSequentialScheduleViewModel result = + await _handler.Handle(new ValidateSequentialSchedule("nope: true", true), CancellationToken.None); + + result.IsValid.ShouldBeFalse(); + result.Messages.ShouldBe(["content is required"]); + result.Json.ShouldBe("{ }"); + } + + [Test] + public async Task Should_Capture_Exception_Message_When_Conversion_Throws() + { + _validator.ToJson(Arg.Any()).Returns(_ => throw new InvalidOperationException("bad yaml")); + + ValidateSequentialScheduleViewModel result = + await _handler.Handle(new ValidateSequentialSchedule(": : :", false), CancellationToken.None); + + result.IsValid.ShouldBeFalse(); + result.Messages.ShouldBe(["bad yaml"]); + result.Json.ShouldBeEmpty(); + } +} diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 8cb9dd7f6..daaf70580 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -2,6 +2,8 @@ using System.Reflection; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Application.Scheduling; +using ErsatzTV.Application.Troubleshooting; +using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; @@ -9,6 +11,7 @@ using ErsatzTV.Core.Api.Playouts; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using LanguageExt; @@ -51,6 +54,9 @@ public class PlayoutControllerTests ShouldHaveActionRoute(nameof(PlayoutController.ReplaceAlternateSchedules), "PUT", "/api/playouts/{id:int}/alternate-schedules"); ShouldHaveActionRoute(nameof(PlayoutController.GetTemplates), "GET", "/api/playouts/{id:int}/templates"); ShouldHaveActionRoute(nameof(PlayoutController.ReplaceTemplates), "PUT", "/api/playouts/{id:int}/templates"); + ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/playouts/{id:int}/blocks"); + ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/playouts/{id:int}/blocks/{blockId:int}/history"); + ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/playouts/history/{id:int}"); } [Test] @@ -855,6 +861,117 @@ public class PlayoutControllerTests Arg.Any()); } + [Test] + public async Task GetBlocks_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetBlocks(404, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetBlocks_Should_Map_Blocks_To_Response_And_Return_200() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new BlockViewModel(10, 1, "Morning", "Toons", 60, BlockStopScheduling.AfterDurationEnd)]); + + IActionResult result = await _controller.GetBlocks(9, CancellationToken.None); + + var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); + list.Count.ShouldBe(1); + list[0].Id.ShouldBe(10); + list[0].Name.ShouldBe("Toons"); + list[0].GroupName.ShouldBe("Morning"); + } + + [Test] + public async Task GetBlockHistory_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetBlockHistory(404, 10, cancellationToken: CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetBlockHistory_Should_Clamp_Paging_And_Map_Entries() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutHistoryViewModel( + 1, + [new PlayoutHistoryViewModel(99, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddHours(1), "k", "d")])); + + IActionResult result = await _controller.GetBlockHistory(9, 10, -5, 999, CancellationToken.None); + + var paged = result.ShouldBeOfType().Value.ShouldBeOfType(); + paged.TotalCount.ShouldBe(1); + paged.Page.Count.ShouldBe(1); + paged.Page[0].Id.ShouldBe(99); + paged.Page[0].Key.ShouldBe("k"); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.PlayoutId == 9 && q.BlockId == 10 && q.PageNum == 0 && q.PageSize == 100), + Arg.Any()); + } + + [Test] + public async Task GetHistoryDetails_Should_Return_200_With_Decoded_Details() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right( + new PlayoutHistoryDetailsViewModel( + PlaybackOrder.Shuffle, + CollectionType.Collection, + "Cartoons", + "Episode", + "S1E1"))); + + IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None); + + var details = result.ShouldBeOfType().Value.ShouldBeOfType(); + details.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle); + details.CollectionType.ShouldBe(CollectionType.Collection); + details.Name.ShouldBe("Cartoons"); + details.MediaItemType.ShouldBe("Episode"); + details.MediaItemTitle.ShouldBe("S1E1"); + } + + [Test] + public async Task GetHistoryDetails_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.GetHistoryDetails(404, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(404); + } + + [Test] + public async Task GetHistoryDetails_Should_Return_422_For_Decode_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad json"))); + + IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + unprocessable.Value.ShouldBeOfType().Status.ShouldBe(422); + } + private static PlayoutAlternateScheduleViewModel MakeAltVm(int id, int index, int programScheduleId) => new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null); diff --git a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs index b9f51a6c3..1c8d8e9f2 100644 --- a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs @@ -6,6 +6,7 @@ using ErsatzTV.Application; using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core.Api.Troubleshooting; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Troubleshooting; @@ -81,4 +82,58 @@ public class TroubleshootControllerTests document.RootElement.GetProperty("Environment").GetProperty("ETV_FOO").GetString().ShouldBe("bar"); document.RootElement.GetProperty("AviSynth").GetProperty("Demuxer").GetBoolean().ShouldBeFalse(); } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route_For_ValidateSchedule() + { + MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.ValidateSchedule)) + ?? throw new AssertionException("Missing action ValidateSchedule"); + + var attribute = action.GetCustomAttributes().Single(); + attribute.Template.ShouldBe("api/troubleshoot/validate-schedule"); + attribute.Name.ShouldBe("ValidateSequentialSchedule"); + } + + [Test] + public void ValidateSchedule_Should_Use_Stable_Request_Dto() + { + MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.ValidateSchedule)) + ?? throw new AssertionException("Missing action ValidateSchedule"); + + action.GetParameters()[0].ParameterType.ShouldBe(typeof(ValidateSequentialScheduleRequest)); + } + + [TestCase("")] + [TestCase(" ")] + public async Task ValidateSchedule_Should_Return_400_For_Empty_Yaml(string yaml) + { + IActionResult result = await _controller.ValidateSchedule( + new ValidateSequentialScheduleRequest(yaml, false), + CancellationToken.None); + + var badRequest = result.ShouldBeOfType(); + badRequest.Value.ShouldBeOfType().Status.ShouldBe(400); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ValidateSchedule_Should_Map_Result_To_Response() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new ValidateSequentialScheduleViewModel(false, ["boom"], "{}")); + + IActionResult result = await _controller.ValidateSchedule( + new ValidateSequentialScheduleRequest("content: []", true), + CancellationToken.None); + + var response = result.ShouldBeOfType().Value + .ShouldBeOfType(); + response.IsValid.ShouldBeFalse(); + response.Messages.ShouldBe(["boom"]); + response.Json.ShouldBe("{}"); + + await _mediator.Received(1).Send( + Arg.Is(q => q.Yaml == "content: []" && q.IsImport), + Arg.Any()); + } } diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index c6294b063..636986c75 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Application.Scheduling; +using ErsatzTV.Application.Troubleshooting; +using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; @@ -18,6 +20,8 @@ namespace ErsatzTV.Controllers.Api; [ApiController] public class PlayoutController(IMediator mediator) : ControllerBase { + private const int MaxPageSize = 100; + [HttpGet("/api/playouts", Name = "GetPlayouts")] [Tags("Playouts")] [EndpointSummary("List playouts")] @@ -430,6 +434,82 @@ public class PlayoutController(IMediator mediator) : ControllerBase return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList()); } + [HttpGet("/api/playouts/{id:int}/blocks", Name = "GetPlayoutBlocks")] + [Tags("Playouts")] + [EndpointSummary("Get the blocks scheduled by a block playout")] + [EndpointDescription( + "Lists the distinct blocks reachable through a Block playout's templates, ordered by group then name. " + + "A playout with no templates (including non-Block playouts) returns an empty list.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetBlocks(int id, CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List blocks = await mediator.Send(new GetAllBlocksForPlayout(id), cancellationToken); + return new OkObjectResult(blocks.Map(ToBlockResponse).ToList()); + } + + [HttpGet("/api/playouts/{id:int}/blocks/{blockId:int}/history", Name = "GetPlayoutBlockHistory")] + [Tags("Playouts")] + [EndpointSummary("Get a block's playout history")] + [EndpointDescription( + "Returns the paged scheduling history for a single block within a block playout, oldest first. Each row's " + + "Key and Details carry raw JSON; decode a row via GET /api/playouts/history/{id}.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedPlayoutHistoryResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetBlockHistory( + int id, + int blockId, + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + int clampedPageNum = Math.Max(0, pageNum); + int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + PagedPlayoutHistoryViewModel result = await mediator.Send( + new GetBlockPlayoutHistory(id, blockId, clampedPageNum, clampedPageSize), + cancellationToken); + + return new OkObjectResult( + new PagedPlayoutHistoryResponseModel( + result.TotalCount, + result.Page.Map(ToHistoryResponse).ToList())); + } + + [HttpGet("/api/playouts/history/{id:int}", Name = "GetPlayoutHistoryDetails")] + [Tags("Playouts")] + [EndpointSummary("Decode a playout history row")] + [EndpointDescription( + "Decodes a single playout history row (by its id) into its playback order, collection, and media-item " + + "details. Returns 422 if the row's stored Key/Details JSON cannot be decoded.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlayoutHistoryDetailsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task GetHistoryDetails(int id, CancellationToken cancellationToken) + { + Either result = + await mediator.Send(new GetPlayoutHistoryDetails(id), cancellationToken); + + return result.Match( + Left: error => error.ToErrorResult(), + Right: vm => (IActionResult)new OkObjectResult(ToDetailsResponse(vm))); + } + [HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")] [Tags("Playouts")] [EndpointSummary("Reset all playouts")] @@ -501,6 +581,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase return Option.None; } + private static BlockResponseModel ToBlockResponse(BlockViewModel vm) => + new(vm.Id, vm.GroupId, vm.GroupName, vm.Name, vm.Minutes, vm.StopScheduling); + + private static PlayoutHistoryResponseModel ToHistoryResponse(PlayoutHistoryViewModel vm) => + new(vm.Id, vm.When, vm.Finish, vm.Key, vm.Details); + + private static PlayoutHistoryDetailsResponseModel ToDetailsResponse(PlayoutHistoryDetailsViewModel vm) => + new(vm.PlaybackOrder, vm.CollectionType, vm.Name, vm.MediaItemType, vm.MediaItemTitle); + private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => PlayoutResponseModel.From( vm.PlayoutId, diff --git a/ErsatzTV/Controllers/Api/Requests/ValidateSequentialScheduleRequest.cs b/ErsatzTV/Controllers/Api/Requests/ValidateSequentialScheduleRequest.cs new file mode 100644 index 000000000..02d33f4a5 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ValidateSequentialScheduleRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Troubleshooting.Queries; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ValidateSequentialScheduleRequest(string Yaml, bool IsImport) +{ + public ValidateSequentialSchedule ToQuery() => new(Yaml, IsImport); +} diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index dc8daee88..cadc54c03 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using System.Text.Json; using System.Text.Json.Serialization; @@ -6,6 +7,7 @@ using ErsatzTV.Application; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting.Queries; +using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Troubleshooting; using ErsatzTV.Core.Domain; @@ -71,6 +73,36 @@ public class TroubleshootController( info.VideoToolboxCapabilities); } + [HttpPost("api/troubleshoot/validate-schedule", Name = "ValidateSequentialSchedule")] + [Tags("Troubleshooting")] + [EndpointSummary("Validate a sequential schedule YAML document")] + [EndpointDescription( + "Validates a sequential-schedule YAML string against the full (or import) schema. Returns whether it is " + + "valid, any validation messages, and the JSON conversion of the YAML. Parse/validator errors are reported " + + "as messages (IsValid=false), never as a 500.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(ValidateSequentialScheduleResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task ValidateSchedule( + [Required] [FromBody] ValidateSequentialScheduleRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Yaml)) + { + return new BadRequestObjectResult( + new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Validation failed", + Detail = "[Yaml] must not be empty" + }); + } + + ValidateSequentialScheduleViewModel result = await mediator.Send(request.ToQuery(), cancellationToken); + return new OkObjectResult( + new ValidateSequentialScheduleResponseModel(result.IsValid, result.Messages, result.Json)); + } + [HttpHead("api/troubleshoot/playback.m3u8")] [HttpGet("api/troubleshoot/playback.m3u8")] [Tags("Troubleshooting")] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index bbcaabb77..894ac4dce 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -6324,6 +6324,251 @@ } } }, + "/api/playouts/{id}/blocks": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Get the blocks scheduled by a block playout", + "description": "Lists the distinct blocks reachable through a Block playout's templates, ordered by group then name. A playout with no templates (including non-Block playouts) returns an empty list.", + "operationId": "GetPlayoutBlocks", + "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/BlockResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/api/playouts/{id}/blocks/{blockId}/history": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Get a block's playout history", + "description": "Returns the paged scheduling history for a single block within a block playout, oldest first. Each row's Key and Details carry raw JSON; decode a row via GET /api/playouts/history/{id}.", + "operationId": "GetPlayoutBlockHistory", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "blockId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedPlayoutHistoryResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedPlayoutHistoryResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedPlayoutHistoryResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/api/playouts/history/{id}": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Decode a playout history row", + "description": "Decodes a single playout history row (by its id) into its playback order, collection, and media-item details. Returns 422 if the row's stored Key/Details JSON cannot be decoded.", + "operationId": "GetPlayoutHistoryDetails", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutHistoryDetailsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutHistoryDetailsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutHistoryDetailsResponseModel" + } + } + } + }, + "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/reset-all": { "post": { "tags": [ @@ -10173,6 +10418,83 @@ } } }, + "/api/troubleshoot/validate-schedule": { + "post": { + "tags": [ + "Troubleshooting" + ], + "summary": "Validate a sequential schedule YAML document", + "description": "Validates a sequential-schedule YAML string against the full (or import) schema. Returns whether it is valid, any validation messages, and the JSON conversion of the YAML. Parse/validator errors are reported as messages (IsValid=false), never as a 500.", + "operationId": "ValidateSequentialSchedule", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ValidateSequentialScheduleRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateSequentialScheduleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ValidateSequentialScheduleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ValidateSequentialScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ValidateSequentialScheduleResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateSequentialScheduleResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ValidateSequentialScheduleResponseModel" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/troubleshoot/playback.m3u8": { "head": { "tags": [ @@ -15158,6 +15480,25 @@ } } }, + "PagedPlayoutHistoryResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutHistoryResponseModel" + } + } + } + }, "PagedPlayoutItemsResponseModel": { "required": [ "totalCount", @@ -15518,6 +15859,63 @@ } } }, + "PlayoutHistoryDetailsResponseModel": { + "required": [ + "playbackOrder", + "collectionType", + "name", + "mediaItemType", + "mediaItemTitle" + ], + "type": "object", + "properties": { + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "name": { + "type": "string" + }, + "mediaItemType": { + "type": "string" + }, + "mediaItemTitle": { + "type": "string" + } + } + }, + "PlayoutHistoryResponseModel": { + "required": [ + "id", + "when", + "finish", + "key", + "details" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "when": { + "type": "string", + "format": "date-time" + }, + "finish": { + "type": "string", + "format": "date-time" + }, + "key": { + "type": "string" + }, + "details": { + "type": "string" + } + } + }, "PlayoutItemResponseModel": { "required": [ "title", @@ -18285,6 +18683,46 @@ ], "type": "string" }, + "ValidateSequentialScheduleRequest": { + "required": [ + "yaml", + "isImport" + ], + "type": "object", + "properties": { + "yaml": { + "type": [ + "null", + "string" + ] + }, + "isImport": { + "type": "boolean" + } + } + }, + "ValidateSequentialScheduleResponseModel": { + "required": [ + "isValid", + "messages", + "json" + ], + "type": "object", + "properties": { + "isValid": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { + "type": "string" + } + }, + "json": { + "type": "string" + } + } + }, "WatermarkFullResponseModel": { "required": [ "id", diff --git a/web/src/App.tsx b/web/src/App.tsx index ffd4fbcf6..75866c97f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,6 +38,7 @@ import { LayoutGrid, Library, Link2, + ListChecks, ListVideo, MonitorPlay, Music, @@ -73,6 +74,8 @@ import { SettingsScreen } from './screens/SettingsScreen'; import { TraktListsScreen } from './screens/TraktListsScreen'; import { TrashScreen } from './screens/TrashScreen'; import { TroubleshootingScreen } from './screens/TroubleshootingScreen'; +import { BlockPlayoutTroubleshootingScreen } from './screens/BlockPlayoutTroubleshootingScreen'; +import { YamlValidatorScreen } from './screens/YamlValidatorScreen'; import { WatermarksScreen } from './screens/WatermarksScreen'; import { BlocksScreen } from './screens/BlocksScreen'; import { DecosScreen } from './screens/DecosScreen'; @@ -180,7 +183,9 @@ type ScreenId = | 'watermarks' | 'settings' | 'logs' - | 'troubleshooting'; + | 'troubleshooting' + | 'blockPlayoutTroubleshooting' + | 'yamlValidator'; interface ScreenRoute { id: ScreenId; @@ -476,6 +481,28 @@ const routes: ScreenRoute[] = [ icon: