@@ -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")]
|
||||
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+27
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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: - ..."
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user