feat(web+api): block-playout history + sequential-YAML validator (#145, #158 items 4–5) #182

Merged
timothy merged 5 commits from feat/145-block-history into main 2026-07-07 22:32:32 +02:00
31 changed files with 2199 additions and 83 deletions
@@ -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<TvContext> dbContextF
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var decodedKey = JsonConvert.DeserializeObject<BlockItemHistoryKey>(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<Details>(request.PlayoutHistory.Details);
if (details?.MediaItemId != null)
{
Option<MediaItem> 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<string>.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);
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public record GetPlayoutHistoryDetails(int Id) : IRequest<Either<BaseError, PlayoutHistoryDetailsViewModel>>;
@@ -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<TvContext> dbContextFactory)
: IRequestHandler<GetPlayoutHistoryDetails, Either<BaseError, PlayoutHistoryDetailsViewModel>>
{
public async Task<Either<BaseError, PlayoutHistoryDetailsViewModel>> Handle(
GetPlayoutHistoryDetails request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<PlayoutHistory> 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");
}
}
@@ -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<PlayoutHistoryDetailsViewModel> Decode(
TvContext dbContext,
string key,
string details,
CancellationToken cancellationToken)
{
var decodedKey = JsonConvert.DeserializeObject<BlockItemHistoryKey>(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>(details);
if (decodedDetails?.MediaItemId != null)
{
Option<MediaItem> 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<string>.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);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Troubleshooting.Queries;
public record ValidateSequentialSchedule(string Yaml, bool IsImport) : IRequest<ValidateSequentialScheduleViewModel>;
@@ -0,0 +1,25 @@
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public class ValidateSequentialScheduleHandler(ISequentialScheduleValidator validator)
: IRequestHandler<ValidateSequentialSchedule, ValidateSequentialScheduleViewModel>
{
public async Task<ValidateSequentialScheduleViewModel> 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<string> 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);
}
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Troubleshooting;
public record ValidateSequentialScheduleViewModel(bool IsValid, List<string> Messages, string Json);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutHistoryResponseModel(int TotalCount, List<PlayoutHistoryResponseModel> Page);
@@ -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);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutHistoryResponseModel(
int Id,
DateTimeOffset When,
DateTimeOffset Finish,
string Key,
string Details);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Troubleshooting;
public record ValidateSequentialScheduleResponseModel(bool IsValid, List<string> Messages, string Json);
@@ -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<BaseError, PlayoutHistoryDetailsViewModel> result =
await handler.Handle(new GetPlayoutHistoryDetails(999), CancellationToken.None);
result.IsLeft.ShouldBeTrue();
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[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<BaseError, PlayoutHistoryDetailsViewModel> 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<BaseError, PlayoutHistoryDetailsViewModel> result =
await handler.Handle(new GetPlayoutHistoryDetails(historyId), CancellationToken.None);
result.IsLeft.ShouldBeTrue();
LeftOf(result).ShouldNotBeOfType<NotFoundError>();
}
private async Task<int> 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<BaseError, PlayoutHistoryDetailsViewModel> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected Left"));
private static PlayoutHistoryDetailsViewModel RightOf(Either<BaseError, PlayoutHistoryDetailsViewModel> either) =>
either.Match(Left: _ => throw new AssertionException("Expected Right"), Right: v => v);
}
@@ -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<ISequentialScheduleValidator>();
_handler = new ValidateSequentialScheduleHandler(_validator);
}
[Test]
public async Task Should_Report_Valid_When_No_Messages()
{
_validator.ToJson(Arg.Any<string>()).Returns("{ }");
_validator.GetValidationMessages(Arg.Any<string>(), Arg.Any<bool>())
.Returns(Task.FromResult<IList<string>>([]));
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<string>()).Returns("{ }");
_validator.GetValidationMessages(Arg.Any<string>(), Arg.Any<bool>())
.Returns(Task.FromResult<IList<string>>(["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<string>()).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();
}
}
@@ -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<CancellationToken>());
}
[Test]
public async Task GetBlocks_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.GetBlocks(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetAllBlocksForPlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetBlocks_Should_Map_Blocks_To_Response_And_Return_200()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
_mediator.Send(Arg.Any<GetAllBlocksForPlayout>(), Arg.Any<CancellationToken>())
.Returns([new BlockViewModel(10, 1, "Morning", "Toons", 60, BlockStopScheduling.AfterDurationEnd)]);
IActionResult result = await _controller.GetBlocks(9, CancellationToken.None);
var list = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<BlockResponseModel>>();
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<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.GetBlockHistory(404, 10, cancellationToken: CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetBlockPlayoutHistory>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetBlockHistory_Should_Clamp_Paging_And_Map_Entries()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
_mediator.Send(Arg.Any<GetBlockPlayoutHistory>(), Arg.Any<CancellationToken>())
.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<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutHistoryResponseModel>();
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<GetBlockPlayoutHistory>(q =>
q.PlayoutId == 9 && q.BlockId == 10 && q.PageNum == 0 && q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetHistoryDetails_Should_Return_200_With_Decoded_Details()
{
_mediator.Send(Arg.Any<GetPlayoutHistoryDetails>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlayoutHistoryDetailsViewModel>(
new PlayoutHistoryDetailsViewModel(
PlaybackOrder.Shuffle,
CollectionType.Collection,
"Cartoons",
"Episode",
"S1E1")));
IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None);
var details = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlayoutHistoryDetailsResponseModel>();
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<GetPlayoutHistoryDetails>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, PlayoutHistoryDetailsViewModel>(new NotFoundError("missing")));
IActionResult result = await _controller.GetHistoryDetails(404, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
}
[Test]
public async Task GetHistoryDetails_Should_Return_422_For_Decode_Error()
{
_mediator.Send(Arg.Any<GetPlayoutHistoryDetails>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, PlayoutHistoryDetailsViewModel>(BaseError.New("bad json")));
IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(422);
}
private static PlayoutAlternateScheduleViewModel MakeAltVm(int id, int index, int programScheduleId) =>
new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
@@ -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<HttpPostAttribute>().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<BadRequestObjectResult>();
badRequest.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(400);
await _mediator.DidNotReceive().Send(Arg.Any<ValidateSequentialSchedule>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ValidateSchedule_Should_Map_Result_To_Response()
{
_mediator.Send(Arg.Any<ValidateSequentialSchedule>(), Arg.Any<CancellationToken>())
.Returns(new ValidateSequentialScheduleViewModel(false, ["boom"], "{}"));
IActionResult result = await _controller.ValidateSchedule(
new ValidateSequentialScheduleRequest("content: []", true),
CancellationToken.None);
var response = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<ValidateSequentialScheduleResponseModel>();
response.IsValid.ShouldBeFalse();
response.Messages.ShouldBe(["boom"]);
response.Json.ShouldBe("{}");
await _mediator.Received(1).Send(
Arg.Is<ValidateSequentialSchedule>(q => q.Yaml == "content: []" && q.IsImport),
Arg.Any<CancellationToken>());
}
}
@@ -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<BlockResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetBlocks(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<BlockViewModel> 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<IActionResult> GetBlockHistory(
int id,
int blockId,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
Option<PlayoutNameViewModel> 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<IActionResult> GetHistoryDetails(int id, CancellationToken cancellationToken)
{
Either<BaseError, PlayoutHistoryDetailsViewModel> 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<BaseError>.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,
@@ -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);
}
@@ -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<IActionResult> 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")]
+438
View File
@@ -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",
+38 -1
View File
@@ -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: <Stethoscope aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
placeholder: 'Troubleshooting workspace'
},
{
id: 'blockPlayoutTroubleshooting',
path: '/app/troubleshooting/blocks',
label: 'Block History',
title: 'Block Playout Troubleshooting',
kicker: 'System',
description: 'Inspect a block playout, its blocks, and each block’s scheduling history.',
icon: <Boxes aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
placeholder: 'Block playout troubleshooting workspace'
},
{
id: 'yamlValidator',
path: '/app/troubleshooting/yaml',
label: 'Schedule Validator',
title: 'Sequential Schedule Validator',
kicker: 'System',
description: 'Validate a sequential-schedule YAML document against the schema.',
icon: <ListChecks aria-hidden="true" size={16} />,
primaryAction: 'Validate',
placeholder: 'Sequential schedule validator workspace'
}
];
@@ -506,6 +533,8 @@ const systemNavIds: ScreenId[] = [
'settings',
'logs',
'troubleshooting',
'blockPlayoutTroubleshooting',
'yamlValidator',
'ffmpegProfiles',
'watermarks'
];
@@ -3641,6 +3670,14 @@ function ScreenContent({
return <TroubleshootingScreen />;
}
if (route.id === 'blockPlayoutTroubleshooting') {
return <BlockPlayoutTroubleshootingScreen />;
}
if (route.id === 'yamlValidator') {
return <YamlValidatorScreen />;
}
return <PlaceholderScreen route={route} />;
}
+27
View File
@@ -787,6 +787,10 @@ export interface components {
"PagedLogEntriesResponseModel": {
"totalCount": number;
"page": Array<components["schemas"]["LogEntryResponseModel"]>;
};
"PagedPlayoutHistoryResponseModel": {
"totalCount": number;
"page": Array<components["schemas"]["PlayoutHistoryResponseModel"]>;
};
"PagedPlayoutItemsResponseModel": {
"totalCount": number;
@@ -853,6 +857,20 @@ export interface components {
"lastBuild": string;
"success": boolean;
"message": null | string;
};
"PlayoutHistoryDetailsResponseModel": {
"playbackOrder": components["schemas"]["PlaybackOrder"];
"collectionType": components["schemas"]["CollectionType"];
"name": string;
"mediaItemType": string;
"mediaItemTitle": string;
};
"PlayoutHistoryResponseModel": {
"id": number;
"when": string;
"finish": string;
"key": string;
"details": string;
};
"PlayoutItemResponseModel": {
"title": null | string;
@@ -1383,6 +1401,15 @@ export interface components {
"blockBehavior": components["schemas"]["XmltvBlockBehavior"];
};
"VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau";
"ValidateSequentialScheduleRequest": {
"yaml": null | string;
"isImport": boolean;
};
"ValidateSequentialScheduleResponseModel": {
"isValid": boolean;
"messages": Array<string>;
"json": string;
};
"WatermarkFullResponseModel": {
"id": number;
"name": string;
+1
View File
@@ -18,6 +18,7 @@ export * from './maintenance';
export * from './mediaItems';
export * from './pickers';
export * from './playlists';
export * from './playoutHistory';
export * from './playouts';
export * from './schedules';
export * from './search';
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getPlayoutBlockHistory, getPlayoutBlocks, getPlayoutHistoryDetails } from './playoutHistory';
function mockJson(body: unknown, status = 200) {
return vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
})
);
}
describe('getPlayoutBlocks', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/playouts/{id}/blocks', async () => {
const fetchSpy = mockJson([{ id: 1, name: 'Morning' }]);
await expect(getPlayoutBlocks(7)).resolves.toHaveLength(1);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/7/blocks');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
});
describe('getPlayoutBlockHistory', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs history without a query string when no params are given', async () => {
const fetchSpy = mockJson({ totalCount: 0, page: [] });
await getPlayoutBlockHistory(7, 3);
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/7/blocks/3/history');
});
it('builds the query string from pageNum and pageSize', async () => {
const fetchSpy = mockJson({ totalCount: 0, page: [] });
await getPlayoutBlockHistory(7, 3, { pageNum: 2, pageSize: 25 });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/7/blocks/3/history?pageNum=2&pageSize=25');
});
});
describe('getPlayoutHistoryDetails', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/playouts/history/{id}', async () => {
const fetchSpy = mockJson({
playbackOrder: 'Shuffle',
collectionType: 'Collection',
name: 'Cartoons',
mediaItemType: 'Episode',
mediaItemTitle: 'S1E1'
});
await expect(getPlayoutHistoryDetails(42)).resolves.toMatchObject({ name: 'Cartoons' });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/history/42');
});
it('rejects with the ApiError status on failure', async () => {
mockJson({ status: 422, title: 'Validation failed' }, 422);
await expect(getPlayoutHistoryDetails(42)).rejects.toMatchObject({ status: 422 });
});
});
+57
View File
@@ -0,0 +1,57 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type PlayoutBlock = components['schemas']['BlockResponseModel'];
export type PlayoutHistoryEntry = components['schemas']['PlayoutHistoryResponseModel'];
export type PagedPlayoutHistory = components['schemas']['PagedPlayoutHistoryResponseModel'];
export type PlayoutHistoryDetails = components['schemas']['PlayoutHistoryDetailsResponseModel'];
export interface GetBlockHistoryParams {
pageNum?: number;
pageSize?: number;
}
export function getPlayoutBlocks(playoutId: number): Promise<PlayoutBlock[]> {
return request<PlayoutBlock[]>(`/api/playouts/${playoutId}/blocks`);
}
export function getPlayoutBlockHistory(
playoutId: number,
blockId: number,
params: GetBlockHistoryParams = {}
): Promise<PagedPlayoutHistory> {
const searchParams = new URLSearchParams();
if (params.pageNum != null) {
searchParams.set('pageNum', String(params.pageNum));
}
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
const queryString = searchParams.toString();
return request<PagedPlayoutHistory>(
`/api/playouts/${playoutId}/blocks/${blockId}/history${queryString ? `?${queryString}` : ''}`
);
}
export function getPlayoutHistoryDetails(historyId: number): Promise<PlayoutHistoryDetails> {
return request<PlayoutHistoryDetails>(`/api/playouts/history/${historyId}`);
}
export function messageFromPlayoutHistoryError(
error: unknown,
fallback = 'Unable to load playout history'
): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+37 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getTroubleshootingInfo } from './troubleshoot';
import { getTroubleshootingInfo, validateSequentialSchedule } from './troubleshoot';
const sampleInfo = {
generalJson: '{"Version":"1.2.3"}',
@@ -41,3 +41,39 @@ describe('getTroubleshootingInfo', () => {
await expect(getTroubleshootingInfo()).rejects.toMatchObject({ status: 500 });
});
});
describe('validateSequentialSchedule', () => {
const sampleResult = { isValid: true, messages: [], json: '{}' };
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/troubleshoot/validate-schedule with the yaml and isImport body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResult), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(validateSequentialSchedule('content: []', true)).resolves.toMatchObject({ isValid: true });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/troubleshoot/validate-schedule');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ yaml: 'content: []', isImport: true });
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 400, title: 'Validation failed' }), {
headers: { 'Content-Type': 'application/json' },
status: 400
})
);
await expect(validateSequentialSchedule('', false)).rejects.toMatchObject({ status: 400 });
});
});
+8
View File
@@ -2,11 +2,19 @@ import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type TroubleshootingInfo = components['schemas']['TroubleshootingInfoResponseModel'];
export type ValidateScheduleResult = components['schemas']['ValidateSequentialScheduleResponseModel'];
export function getTroubleshootingInfo(): Promise<TroubleshootingInfo> {
return request<TroubleshootingInfo>('/api/troubleshoot/info');
}
export function validateSequentialSchedule(yaml: string, isImport: boolean): Promise<ValidateScheduleResult> {
return request<ValidateScheduleResult>('/api/troubleshoot/validate-schedule', {
body: { yaml, isImport },
method: 'POST'
});
}
export function messageFromTroubleshootError(error: unknown, fallback = 'Unable to load troubleshooting info'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
@@ -0,0 +1,90 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BlockPlayoutTroubleshootingScreen } from './BlockPlayoutTroubleshootingScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const playoutsPage = {
totalCount: 2,
page: [
{ id: 5, channelNumber: '1', channelName: 'Cartoons', scheduleKind: 'Block', scheduleName: '', dailyRebuildTime: null, buildStatus: null },
{ id: 6, channelNumber: '2', channelName: 'Movies', scheduleKind: 'Classic', scheduleName: '', dailyRebuildTime: null, buildStatus: null }
]
};
const blocks = [
{ id: 10, groupId: 1, groupName: 'Morning', name: 'Toons', minutes: 60, stopScheduling: 'AfterDurationEnd' }
];
const historyPage = {
totalCount: 1,
page: [{ id: 99, when: '2026-07-07T00:00:00Z', finish: '2026-07-07T01:00:00Z', key: '{"BlockId":10}', details: '{"MediaItemId":3}' }]
};
const details = {
playbackOrder: 'Shuffle',
collectionType: 'Collection',
name: 'Cartoons Collection',
mediaItemType: 'Episode',
mediaItemTitle: 'S1E1 - Pilot'
};
function mockApi() {
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/playouts' && method === 'GET') {
return Promise.resolve(jsonResponse(playoutsPage));
}
if (url === '/api/playouts/5/blocks' && method === 'GET') {
return Promise.resolve(jsonResponse(blocks));
}
if (url.startsWith('/api/playouts/5/blocks/10/history') && method === 'GET') {
return Promise.resolve(jsonResponse(historyPage));
}
if (url === '/api/playouts/history/99' && method === 'GET') {
return Promise.resolve(jsonResponse(details));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
describe('BlockPlayoutTroubleshootingScreen', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
it('only lists Block playouts in the selector', async () => {
mockApi();
render(<BlockPlayoutTroubleshootingScreen />);
await waitFor(() => expect(screen.getByRole('option', { name: '1 - Cartoons' })).toBeTruthy());
expect(screen.queryByRole('option', { name: '2 - Movies' })).toBeNull();
});
it('drills from playout to block to history to decoded details', async () => {
mockApi();
render(<BlockPlayoutTroubleshootingScreen />);
await screen.findByRole('option', { name: '1 - Cartoons' });
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
expect(await screen.findByText('Toons')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'History' }));
expect(await screen.findByText('{"BlockId":10}')).toBeTruthy();
fireEvent.click(screen.getByText('{"BlockId":10}'));
expect(await screen.findByText('Cartoons Collection')).toBeTruthy();
expect(screen.getByText('S1E1 - Pilot')).toBeTruthy();
});
});
@@ -0,0 +1,519 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, History, Search, TriangleAlert } from 'lucide-react';
import { Button, Card, IconButton, Input, Select, Spinner } from '../components';
import {
getPlayoutBlockHistory,
getPlayoutBlocks,
getPlayoutHistoryDetails,
getPlayouts,
messageFromPlayoutHistoryError,
type PlayoutBlock,
type PlayoutHistoryDetails,
type PlayoutHistoryEntry,
type PlayoutSummary
} from '../api';
const PAGE_SIZE_OPTIONS = ['10', '25', '50', '100'];
type PlayoutsState =
| { status: 'loading'; playouts: []; error: null }
| { status: 'success'; playouts: PlayoutSummary[]; error: null }
| { status: 'error'; playouts: []; error: string };
type BlocksState =
| { status: 'idle'; blocks: []; error: null }
| { status: 'loading'; blocks: []; error: null }
| { status: 'success'; blocks: PlayoutBlock[]; error: null }
| { status: 'error'; blocks: []; error: string };
type HistoryState =
| { status: 'loading'; entries: []; totalCount: 0; error: null }
| { status: 'success'; entries: PlayoutHistoryEntry[]; totalCount: number; error: null }
| { status: 'error'; entries: []; totalCount: 0; error: string };
type DetailsState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; details: PlayoutHistoryDetails }
| { status: 'error'; error: string };
interface SelectedBlock {
id: number;
name: string;
}
function formatDateTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
export function BlockPlayoutTroubleshootingScreen() {
const [playoutsState, setPlayoutsState] = useState<PlayoutsState>({
status: 'loading',
playouts: [],
error: null
});
const [selectedPlayoutId, setSelectedPlayoutId] = useState<number | null>(null);
const [blocksState, setBlocksState] = useState<BlocksState>({ status: 'idle', blocks: [], error: null });
const [blockFilter, setBlockFilter] = useState('');
const [selectedBlock, setSelectedBlock] = useState<SelectedBlock | null>(null);
const [pageNum, setPageNum] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [historyState, setHistoryState] = useState<HistoryState>({
status: 'loading',
entries: [],
totalCount: 0,
error: null
});
const [selectedHistoryId, setSelectedHistoryId] = useState<number | null>(null);
const [detailsState, setDetailsState] = useState<DetailsState>({ status: 'idle' });
const activeRef = useRef(true);
const blocksSeqRef = useRef(0);
const historySeqRef = useRef(0);
const detailsSeqRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Load block playouts once (client-side filter to scheduleKind === 'Block').
useEffect(() => {
getPlayouts()
.then((paged) => {
if (!activeRef.current) {
return;
}
const blockPlayouts = (paged.page ?? []).filter((playout) => playout.scheduleKind === 'Block');
setPlayoutsState({ status: 'success', playouts: blockPlayouts, error: null });
})
.catch((error: unknown) => {
if (activeRef.current) {
setPlayoutsState({
status: 'error',
playouts: [],
error: messageFromPlayoutHistoryError(error, 'Unable to load block playouts')
});
}
});
}, []);
// Load blocks whenever the selected playout changes. The loading/idle transition is set in the
// onSelectPlayout handler (never synchronously in this effect) per the screen's setState rules.
useEffect(() => {
if (selectedPlayoutId == null) {
return;
}
const id = ++blocksSeqRef.current;
getPlayoutBlocks(selectedPlayoutId)
.then((blocks) => {
if (activeRef.current && id === blocksSeqRef.current) {
setBlocksState({ status: 'success', blocks, error: null });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === blocksSeqRef.current) {
setBlocksState({
status: 'error',
blocks: [],
error: messageFromPlayoutHistoryError(error, 'Unable to load blocks')
});
}
});
}, [selectedPlayoutId]);
// Load history whenever the selected block or paging changes.
const loadHistory = useCallback(() => {
if (selectedPlayoutId == null || selectedBlock == null) {
return;
}
const id = ++historySeqRef.current;
getPlayoutBlockHistory(selectedPlayoutId, selectedBlock.id, { pageNum, pageSize })
.then((paged) => {
if (activeRef.current && id === historySeqRef.current) {
setHistoryState({
status: 'success',
entries: paged.page ?? [],
totalCount: paged.totalCount ?? 0,
error: null
});
}
})
.catch((error: unknown) => {
if (activeRef.current && id === historySeqRef.current) {
setHistoryState({
status: 'error',
entries: [],
totalCount: 0,
error: messageFromPlayoutHistoryError(error)
});
}
});
}, [pageNum, pageSize, selectedBlock, selectedPlayoutId]);
useEffect(() => {
if (selectedBlock != null) {
loadHistory();
}
}, [loadHistory, selectedBlock]);
// Decode a history row whenever one is selected. The loading/idle transition is set in the
// handlers that change selectedHistoryId (never synchronously in this effect).
useEffect(() => {
if (selectedHistoryId == null) {
return;
}
const id = ++detailsSeqRef.current;
getPlayoutHistoryDetails(selectedHistoryId)
.then((details) => {
if (activeRef.current && id === detailsSeqRef.current) {
setDetailsState({ status: 'success', details });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === detailsSeqRef.current) {
setDetailsState({
status: 'error',
error: messageFromPlayoutHistoryError(error, 'Unable to decode history row')
});
}
});
}, [selectedHistoryId]);
const onSelectPlayout = (value: string) => {
const id = value === '' ? null : Number(value);
setSelectedPlayoutId(id);
setBlocksState(
id == null
? { status: 'idle', blocks: [], error: null }
: { status: 'loading', blocks: [], error: null }
);
setSelectedBlock(null);
setBlockFilter('');
setSelectedHistoryId(null);
setDetailsState({ status: 'idle' });
};
const onOpenBlockHistory = (block: PlayoutBlock) => {
setSelectedBlock({ id: block.id, name: block.name });
setPageNum(0);
setSelectedHistoryId(null);
setDetailsState({ status: 'idle' });
setHistoryState({ status: 'loading', entries: [], totalCount: 0, error: null });
};
const onSelectHistoryRow = (historyId: number) => {
setSelectedHistoryId(historyId);
setDetailsState({ status: 'loading' });
};
const filteredBlocks = useMemo(() => {
if (blocksState.status !== 'success') {
return [];
}
const needle = blockFilter.trim().toLowerCase();
if (needle === '') {
return blocksState.blocks;
}
return blocksState.blocks.filter((block) => block.name.toLowerCase().includes(needle));
}, [blockFilter, blocksState]);
const groupedBlocks = useMemo(() => {
const groups = new Map<string, PlayoutBlock[]>();
for (const block of filteredBlocks) {
const key = block.groupName ?? '';
const existing = groups.get(key);
if (existing) {
existing.push(block);
} else {
groups.set(key, [block]);
}
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [filteredBlocks]);
const playoutOptions = useMemo(() => {
const options = playoutsState.playouts.map((playout) => ({
value: String(playout.id),
label: `${playout.channelNumber} - ${playout.channelName}`
}));
return [{ value: '', label: 'Select a block playout…' }, ...options];
}, [playoutsState.playouts]);
const totalPages =
historyState.status === 'success' ? Math.max(1, Math.ceil(historyState.totalCount / pageSize)) : 1;
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Select
onChange={(event) => onSelectPlayout(event.target.value)}
options={playoutOptions}
style={{ minWidth: 280 }}
value={selectedPlayoutId == null ? '' : String(selectedPlayoutId)}
/>
<span className="ctv-channels-spacer" />
</div>
{playoutsState.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{playoutsState.error}</span>
</div>
)}
{playoutsState.status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading block playouts…</span>
</div>
) : selectedPlayoutId == null ? (
<Card padded>
<div className="ctv-collections-empty">Select a block playout to inspect its blocks.</div>
</Card>
) : (
<>
<div className="ctv-channels-actionbar">
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setBlockFilter(event.target.value)}
placeholder="Search for blocks…"
value={blockFilter}
/>
</div>
{blocksState.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{blocksState.error}</span>
</div>
)}
<Card padded={false}>
{blocksState.status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading blocks…</span>
</div>
) : filteredBlocks.length === 0 ? (
<div className="ctv-collections-empty">
{blocksState.status === 'success' && blocksState.blocks.length > 0
? 'No blocks match this filter.'
: 'This playout schedules no blocks.'}
</div>
) : (
<div className="ctv-channels-table-frame">
<div className="ctv-channels-table-scroll">
<table aria-label="Blocks" className="ctv-channels-table">
<thead>
<tr>
<th>Block</th>
<th style={{ width: 120 }}>Minutes</th>
<th style={{ width: 120 }}>History</th>
</tr>
</thead>
<tbody>
{groupedBlocks.map(([groupName, blocks]) => (
<BlockGroupRows
blocks={blocks}
groupName={groupName}
key={groupName}
onOpenHistory={onOpenBlockHistory}
selectedBlockId={selectedBlock?.id ?? null}
/>
))}
</tbody>
</table>
</div>
</div>
)}
</Card>
{selectedBlock != null && (
<>
<div className="ctv-channels-actionbar">
<strong>History — {selectedBlock.name}</strong>
<span className="ctv-channels-spacer" />
<Select
onChange={(event) => {
setPageNum(0);
setPageSize(Number(event.target.value));
}}
options={PAGE_SIZE_OPTIONS}
style={{ width: 96 }}
value={String(pageSize)}
/>
</div>
{historyState.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{historyState.error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={loadHistory} size="sm" variant="secondary">
Retry
</Button>
</div>
)}
<Card padded={false}>
{historyState.status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading history…</span>
</div>
) : historyState.entries.length === 0 ? (
<div className="ctv-collections-empty">No history recorded for this block.</div>
) : (
<div className="ctv-channels-table-frame">
<div className="ctv-channels-table-scroll">
<table aria-label="Block playout history" className="ctv-channels-table">
<thead>
<tr>
<th>Start</th>
<th>Finish</th>
<th>Key</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{historyState.entries.map((entry) => (
<tr
aria-selected={selectedHistoryId === entry.id}
className={selectedHistoryId === entry.id ? 'ctv-channel-row-selected' : undefined}
key={entry.id}
onClick={() => onSelectHistoryRow(entry.id)}
style={{ cursor: 'pointer' }}
>
<td style={{ whiteSpace: 'nowrap' }}>{formatDateTime(entry.when)}</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatDateTime(entry.finish)}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.key}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.details}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="ctv-channels-footer">
<span>
{historyState.totalCount} entr{historyState.totalCount === 1 ? 'y' : 'ies'}
</span>
<span className="ctv-channels-spacer" />
<IconButton
disabled={pageNum === 0}
onClick={() => setPageNum((current) => Math.max(0, current - 1))}
size="sm"
title="Previous page"
>
<ChevronLeft aria-hidden="true" size={15} />
</IconButton>
<span>
Page <code>{pageNum + 1}</code> of <code>{totalPages}</code>
</span>
<IconButton
disabled={pageNum + 1 >= totalPages}
onClick={() => setPageNum((current) => current + 1)}
size="sm"
title="Next page"
>
<ChevronRight aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
)}
</Card>
<HistoryDetailsPanel state={detailsState} />
</>
)}
</>
)}
</div>
);
}
function BlockGroupRows({
blocks,
groupName,
onOpenHistory,
selectedBlockId
}: {
blocks: PlayoutBlock[];
groupName: string;
onOpenHistory: (block: PlayoutBlock) => void;
selectedBlockId: number | null;
}) {
return (
<>
<tr className="ctv-channel-group-row">
<td colSpan={3}>
<span>{groupName || 'Ungrouped'}</span>
</td>
</tr>
{blocks.map((block) => (
<tr
className={selectedBlockId === block.id ? 'ctv-channel-row-selected' : undefined}
key={block.id}
>
<td>{block.name}</td>
<td>{block.minutes}</td>
<td>
<Button
onClick={() => onOpenHistory(block)}
size="sm"
startIcon={<History aria-hidden="true" size={14} />}
variant="secondary"
>
History
</Button>
</td>
</tr>
))}
</>
);
}
function HistoryDetailsPanel({ state }: { state: DetailsState }) {
if (state.status === 'idle') {
return null;
}
return (
<Card padded subtitle="Decoded from the selected history row" title="History details">
{state.status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={16} />
<span>Decoding…</span>
</div>
) : state.status === 'error' ? (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
</div>
) : (
<dl className="ctv-detail-grid">
<DetailRow label="Playback Order" value={state.details.playbackOrder} />
<DetailRow label="Collection Type" value={state.details.collectionType} />
<DetailRow label="Name" value={state.details.name} />
<DetailRow label="Media Item Type" value={state.details.mediaItemType} />
<DetailRow label="Media Item Title" value={state.details.mediaItemTitle} />
</dl>
)}
</Card>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<>
<dt>{label}</dt>
<dd>{value === '' ? '—' : value}</dd>
</>
);
}
@@ -0,0 +1,66 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { YamlValidatorScreen } from './YamlValidatorScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
describe('YamlValidatorScreen', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
it('validates the entered YAML and shows the JSON output when valid', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ isValid: true, messages: [], json: '{\n "content": []\n}' })
);
render(<YamlValidatorScreen />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'content: []' } });
fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
expect(await screen.findByText('Valid')).toBeTruthy();
expect(screen.getByText(/"content": \[\]/)).toBeTruthy();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/troubleshoot/validate-schedule');
expect(JSON.parse(String(init?.body))).toEqual({ yaml: 'content: []', isImport: false });
});
it('shows validation messages when the schedule is invalid', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ isValid: false, messages: ['content is required'], json: '{}' })
);
render(<YamlValidatorScreen />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'nope: true' } });
fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
expect(await screen.findByText('content is required')).toBeTruthy();
expect(screen.getByText(/1 validation message/)).toBeTruthy();
});
it('sends isImport=true when the import schema switch is enabled', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ isValid: true, messages: [], json: '{}' })
);
render(<YamlValidatorScreen />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'content: []' } });
fireEvent.click(screen.getByRole('switch', { name: 'Import schema' }));
fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({ yaml: 'content: []', isImport: true });
});
});
+112
View File
@@ -0,0 +1,112 @@
import { useRef, useState } from 'react';
import { Check, ListChecks, TriangleAlert } from 'lucide-react';
import { Button, Card, Switch } from '../components';
import { messageFromTroubleshootError, validateSequentialSchedule, type ValidateScheduleResult } from '../api';
type ValidationState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; result: ValidateScheduleResult }
| { status: 'error'; error: string };
export function YamlValidatorScreen() {
const [yaml, setYaml] = useState('');
const [isImport, setIsImport] = useState(false);
const [state, setState] = useState<ValidationState>({ status: 'idle' });
const activeRef = useRef(true);
const seqRef = useRef(0);
const validate = () => {
const id = ++seqRef.current;
setState({ status: 'loading' });
validateSequentialSchedule(yaml, isImport)
.then((result) => {
if (activeRef.current && id === seqRef.current) {
setState({ status: 'success', result });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === seqRef.current) {
setState({ status: 'error', error: messageFromTroubleshootError(error, 'Unable to validate schedule') });
}
});
};
const messageCount = state.status === 'success' ? state.result.messages.length : 0;
const hasMessages = messageCount > 0;
return (
<div className="ctv-collections">
<Card padded subtitle="Paste a sequential-schedule YAML document to validate it against the schema" title="Sequential Schedule Validation">
<label className="ctv-field ctv-field-full">
<span className="ctv-field-label">YAML</span>
<textarea
className="ctv-textarea"
onChange={(event) => setYaml(event.target.value)}
placeholder="content:&#10; - ..."
rows={16}
spellCheck={false}
style={{ fontFamily: 'var(--font-mono)', minHeight: 240, resize: 'vertical', width: '100%' }}
value={yaml}
/>
</label>
<div className="ctv-channels-actionbar" style={{ marginTop: 16 }}>
<Switch checked={isImport} label="Import schema" onChange={setIsImport} />
<span className="ctv-channels-spacer" />
<Button
disabled={yaml.trim() === '' || state.status === 'loading'}
onClick={validate}
startIcon={<ListChecks aria-hidden="true" size={14} />}
>
Validate
</Button>
</div>
</Card>
{state.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
</div>
)}
{state.status === 'success' && (
<>
<Card
padded
subtitle={hasMessages ? undefined : 'The document is valid against the selected schema'}
title={
<span style={{ alignItems: 'center', display: 'inline-flex', gap: 8 }}>
{hasMessages ? (
<TriangleAlert aria-hidden="true" size={16} style={{ color: 'var(--color-danger, #e5484d)' }} />
) : (
<Check aria-hidden="true" size={16} style={{ color: 'var(--color-success, #30a46c)' }} />
)}
{hasMessages ? `${messageCount} validation message${messageCount === 1 ? '' : 's'}` : 'Valid'}
</span>
}
>
{hasMessages ? (
<ul className="ctv-validation-messages">
{state.result.messages.map((message, index) => (
<li key={`${index}-${message}`} style={{ color: 'var(--color-danger, #e5484d)' }}>
{message}
</li>
))}
</ul>
) : (
<p>No validation messages.</p>
)}
</Card>
<Card padded subtitle="YAML converted to JSON" title="JSON">
<pre className="ctv-troubleshoot-pre">
<code>{state.result.json || '(no output)'}</code>
</pre>
</Card>
</>
)}
</div>
);
}
+45
View File
@@ -3485,6 +3485,51 @@
word-break: break-word;
}
.ctv-textarea {
width: 100%;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--ctv-surface-2);
border: 1px solid var(--border-hairline);
color: var(--text-primary);
font: var(--text-xs) / 1.5 var(--font-mono);
}
.ctv-textarea:focus {
outline: none;
border-color: var(--status-accent, var(--ctv-accent, #4c8bf5));
}
.ctv-validation-messages {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: var(--space-3, 6px);
font: var(--text-xs) / 1.5 var(--font-mono);
}
.ctv-detail-grid {
display: grid;
grid-template-columns: minmax(140px, max-content) 1fr;
gap: var(--space-4, 8px) var(--space-7, 16px);
margin: 0;
}
.ctv-detail-grid dt {
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-medium, 500);
letter-spacing: var(--tracking-caps, 0.06em);
text-transform: uppercase;
align-self: center;
}
.ctv-detail-grid dd {
margin: 0;
color: var(--text-primary);
}
.ctv-settings-flush-row {
display: flex;
align-items: center;