Merge pull request 'feat: SPA playback troubleshooting screen + status/subtitles/stream-selectors endpoints (#145)' (#199) from feat/145-troubleshooting-spa into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
This commit was merged in pull request #199.
This commit is contained in:
@@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
@@ -34,6 +35,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
ISongVideoGenerator songVideoGenerator,
|
||||
IWatermarkSelector watermarkSelector,
|
||||
IEntityLocker entityLocker,
|
||||
ITroubleshootingPlaybackStatusStore statusStore,
|
||||
IMediator mediator,
|
||||
LoggingLevelSwitches loggingLevelSwitches,
|
||||
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
|
||||
@@ -68,6 +70,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
@@ -166,6 +169,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting;
|
||||
|
||||
public class RecordTroubleshootingPlaybackStatusHandler(ITroubleshootingPlaybackStatusStore statusStore)
|
||||
: INotificationHandler<PlaybackTroubleshootingCompletedNotification>
|
||||
{
|
||||
public Task Handle(PlaybackTroubleshootingCompletedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
statusStore.RecordCompletion(notification.ExitCode, notification.MaybeSpeed);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Troubleshooting;
|
||||
|
||||
// State machine for the SPA's playback-status poll:
|
||||
// "idle" - no session has run since startup / reset
|
||||
// "running" - a session is in flight (IEntityLocker.IsTroubleshootingPlaybackLocked())
|
||||
// "completed" - the last session finished with exit code 0
|
||||
// "failed" - the last session finished with a non-zero exit code
|
||||
// ExitCode/Speed are populated once a result has been recorded; Logs is the tail (last 500 lines)
|
||||
// of the session's logs.txt when present.
|
||||
public record TroubleshootingPlaybackStatusResponseModel(
|
||||
string State,
|
||||
int? ExitCode,
|
||||
double? Speed,
|
||||
string? Logs);
|
||||
@@ -0,0 +1,11 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Troubleshooting;
|
||||
|
||||
// Mirrors ErsatzTV.Application.Troubleshooting.SubtitleViewModel. Id is the value the SPA passes
|
||||
// back as the m3u8 endpoint's subtitleId query parameter; the remaining fields are display-only.
|
||||
// Language/Title/Codec are nullable because embedded/sidecar subtitles frequently lack them.
|
||||
public record TroubleshootingSubtitleResponseModel(
|
||||
int Id,
|
||||
string? Language,
|
||||
string? Title,
|
||||
string? Codec);
|
||||
@@ -0,0 +1,16 @@
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
|
||||
// Records the outcome of the most recent troubleshooting playback session so the SPA can poll
|
||||
// for completion (the legacy Blazor page instead subscribed to an in-process notification). The
|
||||
// store is reset when a new session starts (see PrepareTroubleshootingPlaybackHandler) and written
|
||||
// by the PlaybackTroubleshootingCompletedNotification handler.
|
||||
public interface ITroubleshootingPlaybackStatusStore
|
||||
{
|
||||
Option<TroubleshootingPlaybackResult> CurrentResult { get; }
|
||||
|
||||
void Reset();
|
||||
|
||||
void RecordCompletion(int exitCode, Option<double> speed);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Troubleshooting;
|
||||
|
||||
public record TroubleshootingPlaybackResult(int ExitCode, Option<double> Speed);
|
||||
@@ -0,0 +1,36 @@
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
|
||||
namespace ErsatzTV.Core.Troubleshooting;
|
||||
|
||||
public class TroubleshootingPlaybackStatusStore : ITroubleshootingPlaybackStatusStore
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private Option<TroubleshootingPlaybackResult> _result = Option<TroubleshootingPlaybackResult>.None;
|
||||
|
||||
public Option<TroubleshootingPlaybackResult> CurrentResult
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_result = Option<TroubleshootingPlaybackResult>.None;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordCompletion(int exitCode, Option<double> speed)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_result = new TroubleshootingPlaybackResult(exitCode, speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public class GraphicsElementControllerTests
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(refresh: false, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(models);
|
||||
}
|
||||
@@ -56,8 +56,34 @@ public class GraphicsElementControllerTests
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(refresh: false, CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Not_Refresh_When_Refresh_Is_False()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
await _controller.GetAll(refresh: false, CancellationToken.None);
|
||||
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<RefreshGraphicsElements>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Refresh_Before_Listing_When_Refresh_Is_True()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
await _controller.GetAll(refresh: true, CancellationToken.None);
|
||||
|
||||
Received.InOrder(() =>
|
||||
{
|
||||
_mediator.Send(Arg.Any<RefreshGraphicsElements>(), Arg.Any<CancellationToken>());
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,7 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/trakt/lists/{id}", "delete", "409")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "404")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "422")]
|
||||
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
|
||||
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||
string path,
|
||||
string method,
|
||||
|
||||
@@ -3,13 +3,21 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Troubleshooting;
|
||||
using ErsatzTV.Application.Troubleshooting.Queries;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Troubleshooting;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
@@ -23,19 +31,45 @@ public class TroubleshootControllerTests
|
||||
{
|
||||
private TroubleshootController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private TroubleshootingPlaybackStatusStore _statusStore = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_statusStore = new TroubleshootingPlaybackStatusStore();
|
||||
_controller = new TroubleshootController(
|
||||
Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
|
||||
Substitute.For<IFileSystem>(),
|
||||
Substitute.For<IConfigElementRepository>(),
|
||||
Substitute.For<ITroubleshootingNotifier>(),
|
||||
_entityLocker,
|
||||
_statusStore,
|
||||
_mediator);
|
||||
}
|
||||
|
||||
private static MediaItemInfo FakeMediaItemInfo() =>
|
||||
new(
|
||||
1,
|
||||
"Title",
|
||||
"Movie",
|
||||
"LocalLibrary",
|
||||
null,
|
||||
"Movies",
|
||||
MediaItemState.Normal,
|
||||
TimeSpan.FromMinutes(90),
|
||||
"1:1",
|
||||
"16:9",
|
||||
"24000/1001",
|
||||
VideoScanKind.Progressive,
|
||||
null,
|
||||
1920,
|
||||
1080,
|
||||
[],
|
||||
[]);
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Info()
|
||||
{
|
||||
@@ -136,4 +170,155 @@ public class TroubleshootControllerTests
|
||||
Arg.Is<ValidateSequentialSchedule>(q => q.Yaml == "content: []" && q.IsImport),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_StreamSelectors()
|
||||
{
|
||||
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetStreamSelectors))
|
||||
?? throw new AssertionException("Missing action GetStreamSelectors");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/stream-selectors");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingStreamSelectors");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetStreamSelectors_Should_Return_Selector_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["a.yml", "b.yml"]);
|
||||
|
||||
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(["a.yml", "b.yml"]);
|
||||
await _mediator.Received(1).Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Subtitles()
|
||||
{
|
||||
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetSubtitles))
|
||||
?? throw new AssertionException("Missing action GetSubtitles");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/subtitles/{mediaItemId:int}");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingSubtitles");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetSubtitles_Should_Return_404_For_Unknown_Media_Item()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("nope")));
|
||||
|
||||
IActionResult result = await _controller.GetSubtitles(999, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetSubtitles_Should_Map_ViewModels_To_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
|
||||
_mediator.Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<SubtitleViewModel> { new(7, "eng", "English", "subrip") });
|
||||
|
||||
IActionResult result = await _controller.GetSubtitles(1, CancellationToken.None);
|
||||
|
||||
var subtitles = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<TroubleshootingSubtitleResponseModel>>();
|
||||
subtitles.Count.ShouldBe(1);
|
||||
subtitles[0].Id.ShouldBe(7);
|
||||
subtitles[0].Language.ShouldBe("eng");
|
||||
subtitles[0].Title.ShouldBe("English");
|
||||
subtitles[0].Codec.ShouldBe("subrip");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackStatus()
|
||||
{
|
||||
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetPlaybackStatus))
|
||||
?? throw new AssertionException("Missing action GetPlaybackStatus");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/status");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("idle");
|
||||
result.ExitCode.ShouldBeNull();
|
||||
result.Speed.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Running_When_Locked()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(true);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("running");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Completed_With_ExitCode_And_Speed()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
_statusStore.RecordCompletion(0, 1.5);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("completed");
|
||||
result.ExitCode.ShouldBe(0);
|
||||
result.Speed.ShouldBe(1.5);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Failed_For_NonZero_ExitCode()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
_statusStore.RecordCompletion(1, Option<double>.None);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("failed");
|
||||
result.ExitCode.ShouldBe(1);
|
||||
result.Speed.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RecordTroubleshootingPlaybackStatusHandler_Should_Record_Result_In_Store()
|
||||
{
|
||||
var store = new TroubleshootingPlaybackStatusStore();
|
||||
var handler = new RecordTroubleshootingPlaybackStatusHandler(store);
|
||||
|
||||
store.CurrentResult.IsNone.ShouldBeTrue();
|
||||
|
||||
await handler.Handle(
|
||||
new PlaybackTroubleshootingCompletedNotification(0, Option<Exception>.None, 2.0),
|
||||
CancellationToken.None);
|
||||
|
||||
store.CurrentResult.IsSome.ShouldBeTrue();
|
||||
TroubleshootingPlaybackResult recorded =
|
||||
store.CurrentResult.IfNone(() => throw new AssertionException("Expected a recorded result"));
|
||||
recorded.ExitCode.ShouldBe(0);
|
||||
recorded.Speed.IfNone(-1).ShouldBe(2.0);
|
||||
|
||||
store.Reset();
|
||||
store.CurrentResult.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,20 @@ public class GraphicsElementController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
|
||||
[Tags("Graphics Elements")]
|
||||
[EndpointSummary("Get all graphics elements")]
|
||||
[EndpointDescription(
|
||||
"Returns all graphics elements. Pass refresh=true to first re-sync the on-disk graphics element " +
|
||||
"definitions into the database (matching the legacy Blazor behavior) so newly added files appear.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(
|
||||
[FromQuery] bool refresh,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (refresh)
|
||||
{
|
||||
await mediator.Send(new RefreshGraphicsElements(), cancellationToken);
|
||||
}
|
||||
|
||||
return await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ using ErsatzTV.Application.Troubleshooting.Queries;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Troubleshooting;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -27,6 +31,8 @@ public class TroubleshootController(
|
||||
IFileSystem fileSystem,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ITroubleshootingNotifier notifier,
|
||||
IEntityLocker entityLocker,
|
||||
ITroubleshootingPlaybackStatusStore statusStore,
|
||||
IMediator mediator) : ControllerBase
|
||||
{
|
||||
private static readonly JsonSerializerOptions GeneralJsonOptions = new()
|
||||
@@ -291,4 +297,94 @@ public class TroubleshootController(
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("api/troubleshoot/playback/stream-selectors", Name = "GetTroubleshootingStreamSelectors")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("List available channel stream selectors")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
|
||||
|
||||
[HttpGet("api/troubleshoot/playback/subtitles/{mediaItemId:int}", Name = "GetTroubleshootingSubtitles")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("List selectable subtitle streams for a media item")]
|
||||
[EndpointDescription(
|
||||
"Returns the subtitle streams that can be burned in for a troubleshooting playback. Each item's id is the " +
|
||||
"value to pass back as the playback.m3u8 endpoint's subtitleId query parameter.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<TroubleshootingSubtitleResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSubtitles(int mediaItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, MediaItemInfo> maybeMediaItem =
|
||||
await mediator.Send(new GetMediaItemInfo(mediaItemId), cancellationToken);
|
||||
if (maybeMediaItem.IsLeft)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<SubtitleViewModel> subtitles =
|
||||
await mediator.Send(new GetTroubleshootingSubtitles(mediaItemId), cancellationToken);
|
||||
|
||||
return new OkObjectResult(
|
||||
subtitles
|
||||
.Map(s => new TroubleshootingSubtitleResponseModel(s.Id, s.Language, s.Title, s.Codec))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
[HttpGet("api/troubleshoot/playback/status", Name = "GetTroubleshootingPlaybackStatus")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Get the status of the current or last troubleshooting playback session")]
|
||||
[EndpointDescription(
|
||||
"Reports whether a troubleshooting playback is idle, running, completed, or failed, along with the last " +
|
||||
"session's ffmpeg exit code, playback speed, and a tail (last 500 lines) of its log output.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(TroubleshootingPlaybackStatusResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<TroubleshootingPlaybackStatusResponseModel> GetPlaybackStatus(CancellationToken cancellationToken)
|
||||
{
|
||||
bool running = entityLocker.IsTroubleshootingPlaybackLocked();
|
||||
Option<TroubleshootingPlaybackResult> maybeResult = statusStore.CurrentResult;
|
||||
|
||||
string state = "idle";
|
||||
int? exitCode = null;
|
||||
double? speed = null;
|
||||
|
||||
if (running)
|
||||
{
|
||||
state = "running";
|
||||
}
|
||||
|
||||
foreach (TroubleshootingPlaybackResult result in maybeResult)
|
||||
{
|
||||
exitCode = result.ExitCode;
|
||||
speed = result.Speed.MatchUnsafe(v => (double?)v, () => null);
|
||||
if (!running)
|
||||
{
|
||||
state = result.ExitCode == 0 ? "completed" : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
string logs = await ReadTroubleshootingLogTail(cancellationToken);
|
||||
|
||||
return new TroubleshootingPlaybackStatusResponseModel(state, exitCode, speed, logs);
|
||||
}
|
||||
|
||||
private async Task<string> ReadTroubleshootingLogTail(CancellationToken cancellationToken)
|
||||
{
|
||||
const int MaxLines = 500;
|
||||
string logFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "logs.txt");
|
||||
if (!fileSystem.File.Exists(logFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string[] lines = await fileSystem.File.ReadAllLinesAsync(logFile, cancellationToken);
|
||||
if (lines.Length > MaxLines)
|
||||
{
|
||||
lines = lines[^MaxLines..];
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,6 +832,7 @@ public class Startup
|
||||
services.AddSingleton<ISmartCollectionCache, SmartCollectionCache>();
|
||||
services.AddSingleton<SearchQueryParser>();
|
||||
services.AddSingleton<ITroubleshootingNotifier, TroubleshootingNotifier>();
|
||||
services.AddSingleton<ITroubleshootingPlaybackStatusStore, TroubleshootingPlaybackStatusStore>();
|
||||
services.AddSingleton<CustomFontMapper>();
|
||||
services.AddSingleton<GraphicsEngineFonts>();
|
||||
services.AddSingleton(Program.InMemoryLogService);
|
||||
|
||||
@@ -4874,7 +4874,17 @@
|
||||
"Graphics Elements"
|
||||
],
|
||||
"summary": "Get all graphics elements",
|
||||
"description": "Returns all graphics elements. Pass refresh=true to first re-sync the on-disk graphics element definitions into the database (matching the legacy Blazor behavior) so newly added files appear.",
|
||||
"operationId": "GetGraphicsElements",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "refresh",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -12804,6 +12814,150 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/troubleshoot/playback/stream-selectors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Troubleshooting"
|
||||
],
|
||||
"summary": "List available channel stream selectors",
|
||||
"operationId": "GetTroubleshootingStreamSelectors",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/troubleshoot/playback/subtitles/{mediaItemId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Troubleshooting"
|
||||
],
|
||||
"summary": "List selectable subtitle streams for a media item",
|
||||
"description": "Returns the subtitle streams that can be burned in for a troubleshooting playback. Each item's id is the value to pass back as the playback.m3u8 endpoint's subtitleId query parameter.",
|
||||
"operationId": "GetTroubleshootingSubtitles",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "mediaItemId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TroubleshootingSubtitleResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TroubleshootingSubtitleResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TroubleshootingSubtitleResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/troubleshoot/playback/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Troubleshooting"
|
||||
],
|
||||
"summary": "Get the status of the current or last troubleshooting playback session",
|
||||
"description": "Reports whether a troubleshooting playback is idle, running, completed, or failed, along with the last session's ffmpeg exit code, playback speed, and a tail (last 500 lines) of its log output.",
|
||||
"operationId": "GetTroubleshootingPlaybackStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TroubleshootingPlaybackStatusResponseModel"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TroubleshootingPlaybackStatusResponseModel"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TroubleshootingPlaybackStatusResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/version": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -20852,6 +21006,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TroubleshootingPlaybackStatusResponseModel": {
|
||||
"required": [
|
||||
"state",
|
||||
"exitCode",
|
||||
"speed",
|
||||
"logs"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string"
|
||||
},
|
||||
"exitCode": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"speed": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"logs": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"TroubleshootingSubtitleResponseModel": {
|
||||
"required": [
|
||||
"id",
|
||||
"language",
|
||||
"title",
|
||||
"codec"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"language": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"codec": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"UiSettingsResponseModel": {
|
||||
"required": [
|
||||
"isDarkMode",
|
||||
|
||||
@@ -65,6 +65,7 @@ been added to the redirect map yet.
|
||||
| `/system/troubleshooting` | `Troubleshooting/Troubleshooting.razor` | `/app/troubleshooting` | |
|
||||
| `/system/troubleshooting/block-playout` | `Troubleshooting/BlockPlayoutTroubleshooting.razor` (+`BlockPlayoutHistory.razor`) | `/app/troubleshooting/blocks` | **covered by PR #182 / #145** |
|
||||
| `/system/troubleshooting/sequential-schedule` | `Troubleshooting/YamlValidator.razor` | `/app/troubleshooting/yaml` | **covered by PR #182 / #145** |
|
||||
| `/system/troubleshooting/playback` | `Troubleshooting/PlaybackTroubleshooting.razor` | `/app/troubleshooting/playback` | **covered by #145** — no nav entry; entry points are the Channels table Troubleshoot action (`?channel={id}`) and the movie detail page (`?mediaItem={id}`) only for now — other media kinds (episodes, music videos, …) need a hand-built `?mediaItem={id}` URL |
|
||||
| `/blocks`, `/blocks/{Id:int}` | `Blocks.razor`, `BlockEditor.razor` | `/app/blocks`(`/{id}`) | allowSubPaths; #144 S1 |
|
||||
| `/templates`, `/templates/{Id:int}` | `Templates.razor`, `TemplateEditor.razor` | `/app/templates`(`/{id}`) | allowSubPaths; #144 S2 |
|
||||
| `/decos`, `/decos/{Id:int}` | `Decos.razor`, `DecoEditor.razor` | `/app/decos`(`/{id}`) | allowSubPaths; #144 S3 |
|
||||
@@ -125,11 +126,11 @@ SPA `/app/collections` items view lists real members instead of the old lossy Lu
|
||||
search preview. The `POST /api/collections/{id}/items` bogus-id case already returns 422 (guarded by
|
||||
`AddItemsToCollectionHandler.ValidateMediaItems`), not 500.
|
||||
|
||||
### Playback troubleshooting — #145
|
||||
### Playback troubleshooting — #145 DONE
|
||||
|
||||
| Blazor route | File | Blocking issue |
|
||||
|---|---|---|
|
||||
| `/system/troubleshooting/playback` | `Troubleshooting/PlaybackTroubleshooting.razor` | #145 (playback troubleshooting diagnostics API) — distinct from block-playout troubleshooting, which is already covered (Section 2) |
|
||||
Nothing remains here. The playback troubleshooting screen (`/app/troubleshooting/playback`,
|
||||
`PlaybackTroubleshootingScreen.tsx`) is built and now lives in Section 2. Section 3 has no remaining
|
||||
blocking rows — all previously-listed gaps (#151/#152/#153/#155 above, #145 here) are resolved.
|
||||
|
||||
## Section 4 — Blazor home / escape hatch
|
||||
|
||||
|
||||
@@ -96,3 +96,34 @@ tracked as still-to-come under #185. Drafting this doc set also surfaced a drift
|
||||
`ErsatzTV/Controllers/Api/` are missing from it — see `docs/api-conventions.md` §6) — tracked as a
|
||||
follow-up under #184 rather than fixed inline, since it's a pre-existing gap, not something this
|
||||
doc-drafting pass caused.
|
||||
|
||||
## 2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel
|
||||
|
||||
The SPA playback-troubleshooting screen (`PlaybackTroubleshootingScreen.tsx`, #145) reports FFmpeg
|
||||
completion by **polling `GET /api/troubleshoot/playback/status` every ~2s** while a session is
|
||||
running (plus one poll on mount so a session started elsewhere still gates Play), rather than a
|
||||
server push. The status endpoint returns `{ state, exitCode, speed, logs }`; the screen captures the
|
||||
running→completed/failed transition in local component state and surfaces a completion notice
|
||||
(success on exit 0, warning otherwise) — the SPA equivalent of the Blazor page's MediatR
|
||||
`ICourier`/`ISnackbar` `PlaybackTroubleshootingCompletedNotification`. Chosen over SignalR/SSE
|
||||
because the SPA has **no push channel** and troubleshooting sessions are short and user-initiated, so
|
||||
a lightweight poll (started on Play, stopped on settle/unmount) is simpler than standing up a new
|
||||
real-time transport. Speed thresholds and the "(Speed: Nx)" badge colors are copied verbatim from the
|
||||
Blazor `GetSpeedClass` (red <0.9, green >1.1, amber otherwise).
|
||||
|
||||
## 2026-07-09 — datetime-local instead of Chronic natural-language start parsing
|
||||
|
||||
The channel-mode "Date and Time" input in the SPA playback-troubleshooting screen uses a native
|
||||
`<input type="datetime-local">`, a **deliberate deviation** from the Blazor page, which parsed a
|
||||
free-text field with `Chronic.Core.Parser` (natural language like "yesterday at 8pm"). The SPA has no
|
||||
Chronic dependency and a picker is unambiguous; the selected local datetime is sent to
|
||||
`playback.m3u8` as an ISO-8601 `start` param via `new Date(value).toISOString()`, which the
|
||||
controller binds to `DateTimeOffset?` exactly as the Blazor round-trip (`"o"`) format did.
|
||||
|
||||
## 2026-07-09 — SPA gates Download Media Sample while a session is active
|
||||
|
||||
Minor intentional deviation: the SPA playback-troubleshooting screen disables **Download Media
|
||||
Sample** (alongside Download Results) while a troubleshooting session is starting/running; Blazor
|
||||
only gated Download Results. Both downloads compete with the live transcode for I/O and the sample
|
||||
archiver reads the same media file, so gating both during a session is strictly safer and costs
|
||||
nothing (sessions are short).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
122 endpoints, 195 operations.
|
||||
125 endpoints, 198 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -361,6 +361,9 @@
|
||||
| HEAD | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
|
||||
| GET | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
|
||||
| HEAD | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
|
||||
| GET | `/api/troubleshoot/playback/status` | GetTroubleshootingPlaybackStatus | Get the status of the current or last troubleshooting playback session |
|
||||
| GET | `/api/troubleshoot/playback/stream-selectors` | GetTroubleshootingStreamSelectors | List available channel stream selectors |
|
||||
| GET | `/api/troubleshoot/playback/subtitles/{mediaItemId}` | GetTroubleshootingSubtitles | List selectable subtitle streams for a media item |
|
||||
| POST | `/api/troubleshoot/validate-schedule` | ValidateSequentialSchedule | Validate a sequential schedule YAML document |
|
||||
|
||||
## Version
|
||||
|
||||
@@ -83,6 +83,23 @@ src>`** — since PR #181, API responses already return rooted, directly-usable
|
||||
`api-conventions.md` §4). **Do not** client-side-prefix artwork paths (no `/artwork/posters/` string
|
||||
building in SPA code) — if you see that pattern, it's stale/wrong.
|
||||
|
||||
## 5b. HLS video preview
|
||||
|
||||
Screens that preview an ErsatzTV HLS stream use the reusable `HlsPlayer` component
|
||||
(`web/src/media/HlsPlayer.tsx`, introduced with #145). Pass it a `src` (the `.m3u8` URL, or `null`
|
||||
for idle) and — when the manifest GET itself starts a server-side session (e.g. troubleshooting
|
||||
`playback.m3u8`) — a `playToken` you increment per play, so a repeat play with an identical URL
|
||||
still tears down and re-attaches (an unchanged `src` alone is a state no-op that never issues a new
|
||||
request); it attaches `hls.js` when Media Source Extensions are available and falls back to native
|
||||
HLS (`video.canPlayType('application/vnd.apple.mpegurl')`, i.e. Safari) otherwise, and tears down the
|
||||
`hls.js` instance on `src` change and unmount. Its config mirrors the legacy `_Host.cshtml`
|
||||
`previewChannel` (`liveDurationInfinity: true` + an unbounded manifest `maxTimeToFirstByteMs`) because
|
||||
the troubleshooting `playback.m3u8` endpoint blocks until segments exist before it 302s to the live
|
||||
manifest. **In tests, mock `hls.js` wholesale** (`vi.mock('hls.js', …)` with a class exposing
|
||||
`static isSupported()`, `static Events`, and `loadSource`/`attachMedia`/`on`/`destroy`) so jsdom never
|
||||
touches a real `MediaSource`; assert the manifest URL via the mocked `loadSource` spy (see
|
||||
`PlaybackTroubleshootingScreen.test.tsx`).
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
|
||||
|
||||
Generated
+7
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"hls.js": "^1.6.16",
|
||||
"lucide-react": "1.23.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
@@ -2359,6 +2360,12 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
|
||||
+4
-3
@@ -14,11 +14,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"vite": "8.1.3",
|
||||
"typescript": "6.0.3",
|
||||
"hls.js": "^1.6.16",
|
||||
"lucide-react": "1.23.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"lucide-react": "1.23.0"
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
|
||||
+22
-1
@@ -88,6 +88,7 @@ import { TraktListsScreen } from './screens/TraktListsScreen';
|
||||
import { TrashScreen } from './screens/TrashScreen';
|
||||
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
|
||||
import { BlockPlayoutTroubleshootingScreen } from './screens/BlockPlayoutTroubleshootingScreen';
|
||||
import { PlaybackTroubleshootingScreen } from './screens/PlaybackTroubleshootingScreen';
|
||||
import { YamlValidatorScreen } from './screens/YamlValidatorScreen';
|
||||
import { WatermarksScreen } from './screens/WatermarksScreen';
|
||||
import { BlocksScreen } from './screens/BlocksScreen';
|
||||
@@ -201,6 +202,7 @@ type ScreenId =
|
||||
| 'logs'
|
||||
| 'troubleshooting'
|
||||
| 'blockPlayoutTroubleshooting'
|
||||
| 'playbackTroubleshooting'
|
||||
| 'yamlValidator';
|
||||
|
||||
interface ScreenRoute {
|
||||
@@ -542,6 +544,17 @@ const routes: ScreenRoute[] = [
|
||||
primaryAction: 'Refresh',
|
||||
placeholder: 'Block playout troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
id: 'playbackTroubleshooting',
|
||||
path: '/app/troubleshooting/playback',
|
||||
label: 'Playback Troubleshooting',
|
||||
title: 'Playback Troubleshooting',
|
||||
kicker: 'System',
|
||||
description: 'Preview channel or media-item playback with a chosen FFmpeg profile and inspect the transcode logs.',
|
||||
icon: <Stethoscope aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Play',
|
||||
placeholder: 'Playback troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
id: 'yamlValidator',
|
||||
path: '/app/troubleshooting/yaml',
|
||||
@@ -1560,7 +1573,11 @@ function ChannelTableRow({
|
||||
<IconButton onClick={() => navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled size="sm" title={`Troubleshoot ${channel.name}`}>
|
||||
<IconButton
|
||||
onClick={() => navigateToPath(`/app/troubleshooting/playback?channel=${channel.id}`)}
|
||||
size="sm"
|
||||
title={`Troubleshoot ${channel.name}`}
|
||||
>
|
||||
<Stethoscope aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={mutating} onClick={() => onDelete(channel)} size="sm" title={`Delete ${channel.name}`}>
|
||||
@@ -3776,6 +3793,10 @@ function ScreenContent({
|
||||
return <BlockPlayoutTroubleshootingScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'playbackTroubleshooting') {
|
||||
return <PlaybackTroubleshootingScreen key={window.location.search} />;
|
||||
}
|
||||
|
||||
if (route.id === 'yamlValidator') {
|
||||
return <YamlValidatorScreen />;
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -1410,6 +1410,18 @@ export interface components {
|
||||
"qsvCapabilities": null | string;
|
||||
"vaapiCapabilities": null | string;
|
||||
"videoToolboxCapabilities": null | string;
|
||||
};
|
||||
"TroubleshootingPlaybackStatusResponseModel": {
|
||||
"state": string;
|
||||
"exitCode": null | number;
|
||||
"speed": null | number;
|
||||
"logs": null | string;
|
||||
};
|
||||
"TroubleshootingSubtitleResponseModel": {
|
||||
"id": number;
|
||||
"language": null | string;
|
||||
"title": null | string;
|
||||
"codec": null | string;
|
||||
};
|
||||
"UiSettingsResponseModel": {
|
||||
"isDarkMode": boolean;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getGraphicsElements } from './pickers';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
describe('getGraphicsElements', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs /api/graphics-elements without refresh by default', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await getGraphicsElements();
|
||||
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/api/graphics-elements');
|
||||
});
|
||||
|
||||
it('passes refresh=true to re-sync on-disk definitions first', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await getGraphicsElements(true);
|
||||
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/api/graphics-elements?refresh=true');
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,11 @@ export function getWatermarks(): Promise<Watermark[]> {
|
||||
return request<Watermark[]>('/api/watermarks').then(sortByName);
|
||||
}
|
||||
|
||||
export function getGraphicsElements(): Promise<GraphicsElement[]> {
|
||||
return request<GraphicsElement[]>('/api/graphics-elements').then(sortByName);
|
||||
// Pass refresh=true to first re-sync the on-disk graphics element definitions into the database
|
||||
// (matching legacy Blazor's RefreshGraphicsElements-before-list) so newly added files appear.
|
||||
export function getGraphicsElements(refresh = false): Promise<GraphicsElement[]> {
|
||||
const url = refresh ? '/api/graphics-elements?refresh=true' : '/api/graphics-elements';
|
||||
return request<GraphicsElement[]>(url).then(sortByName);
|
||||
}
|
||||
|
||||
export function getFFmpegProfiles(): Promise<FFmpegProfile[]> {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getTroubleshootingInfo, validateSequentialSchedule } from './troubleshoot';
|
||||
import {
|
||||
getTroubleshootingInfo,
|
||||
getTroubleshootingPlaybackStatus,
|
||||
getTroubleshootingStreamSelectors,
|
||||
getTroubleshootingSubtitles,
|
||||
validateSequentialSchedule
|
||||
} from './troubleshoot';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const sampleInfo = {
|
||||
generalJson: '{"Version":"1.2.3"}',
|
||||
@@ -77,3 +87,35 @@ describe('validateSequentialSchedule', () => {
|
||||
await expect(validateSequentialSchedule('', false)).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('troubleshooting playback wrappers', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs the stream selectors list', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(['a', 'b']));
|
||||
|
||||
await expect(getTroubleshootingStreamSelectors()).resolves.toEqual(['a', 'b']);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/api/troubleshoot/playback/stream-selectors');
|
||||
});
|
||||
|
||||
it('GETs the subtitle list for a media item id', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse([{ id: 3, language: 'eng', title: 'English', codec: 'subrip' }]));
|
||||
|
||||
await expect(getTroubleshootingSubtitles(42)).resolves.toMatchObject([{ id: 3 }]);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/api/troubleshoot/playback/subtitles/42');
|
||||
});
|
||||
|
||||
it('GETs the playback status', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ state: 'completed', exitCode: 0, speed: 1.2, logs: 'ok' }));
|
||||
|
||||
await expect(getTroubleshootingPlaybackStatus()).resolves.toMatchObject({ state: 'completed', speed: 1.2 });
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('/api/troubleshoot/playback/status');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,31 @@ import type { components } from './generated/v1';
|
||||
|
||||
export type TroubleshootingInfo = components['schemas']['TroubleshootingInfoResponseModel'];
|
||||
export type ValidateScheduleResult = components['schemas']['ValidateSequentialScheduleResponseModel'];
|
||||
export type TroubleshootingSubtitle = components['schemas']['TroubleshootingSubtitleResponseModel'];
|
||||
export type TroubleshootingPlaybackStatus = components['schemas']['TroubleshootingPlaybackStatusResponseModel'];
|
||||
|
||||
export function getTroubleshootingInfo(): Promise<TroubleshootingInfo> {
|
||||
return request<TroubleshootingInfo>('/api/troubleshoot/info');
|
||||
}
|
||||
|
||||
// The stream selectors available for a troubleshooting playback (channel "smart" selectors).
|
||||
export function getTroubleshootingStreamSelectors(): Promise<string[]> {
|
||||
return request<string[]>('/api/troubleshoot/playback/stream-selectors');
|
||||
}
|
||||
|
||||
// Subtitle streams that can be burned in for a media item. Each item's `id` is the value to pass
|
||||
// back as the playback.m3u8 endpoint's `subtitleId` query param.
|
||||
export function getTroubleshootingSubtitles(mediaItemId: number): Promise<TroubleshootingSubtitle[]> {
|
||||
return request<TroubleshootingSubtitle[]>(`/api/troubleshoot/playback/subtitles/${mediaItemId}`);
|
||||
}
|
||||
|
||||
// Status of the current or last troubleshooting playback session. `state` is idle|running|
|
||||
// completed|failed; exitCode/speed are null until completed/failed; logs is the log tail (null
|
||||
// until written).
|
||||
export function getTroubleshootingPlaybackStatus(): Promise<TroubleshootingPlaybackStatus> {
|
||||
return request<TroubleshootingPlaybackStatus>('/api/troubleshoot/playback/status');
|
||||
}
|
||||
|
||||
export function validateSequentialSchedule(yaml: string, isImport: boolean): Promise<ValidateScheduleResult> {
|
||||
return request<ValidateScheduleResult>('/api/troubleshoot/validate-schedule', {
|
||||
body: { yaml, isImport },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, type CSSProperties } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
export interface HlsPlayerProps {
|
||||
// The HLS manifest (.m3u8) URL to play, or null to render an idle player. Setting a new src
|
||||
// tears down any previous hls.js instance and re-attaches.
|
||||
src: string | null;
|
||||
// Increment to force a full teardown/re-attach even when src is unchanged. Needed when the
|
||||
// manifest GET itself starts a server-side session (e.g. troubleshooting playback.m3u8): a repeat
|
||||
// play with identical settings yields an identical URL, which would otherwise be a state no-op
|
||||
// that never issues a new request.
|
||||
playToken?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// A small HLS video player. Uses hls.js when Media Source Extensions are available (config mirrors
|
||||
// _Host.cshtml's legacy `previewChannel`: the troubleshooting playback.m3u8 endpoint blocks until
|
||||
// segments exist, so the manifest request must tolerate an unbounded time-to-first-byte), and falls
|
||||
// back to the browser's native HLS support (Safari) otherwise. Reusable across any screen that needs
|
||||
// to preview an ErsatzTV HLS stream.
|
||||
export function HlsPlayer({ src, playToken = 0, className, style }: HlsPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
|
||||
const destroyHls = () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !src) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tear down any previous instance before re-attaching for the new source.
|
||||
destroyHls();
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
liveDurationInfinity: true,
|
||||
manifestLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: Infinity,
|
||||
maxLoadTimeMs: 60_000,
|
||||
timeoutRetry: null,
|
||||
errorRetry: null
|
||||
}
|
||||
}
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
void video.play().catch(() => {
|
||||
// Autoplay may be blocked; the user can press play manually.
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
destroyHls();
|
||||
};
|
||||
}
|
||||
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src;
|
||||
const onCanPlay = () => {
|
||||
void video.play().catch(() => {
|
||||
// Autoplay may be blocked; the user can press play manually.
|
||||
});
|
||||
};
|
||||
video.addEventListener('canplay', onCanPlay);
|
||||
return () => {
|
||||
video.removeEventListener('canplay', onCanPlay);
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
// playToken forces re-attachment for repeat plays of an identical src (see HlsPlayerProps).
|
||||
}, [src, playToken]);
|
||||
|
||||
// Final unmount safety net (covers the hls.js instance in every path).
|
||||
useEffect(
|
||||
() => () => {
|
||||
destroyHls();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return <video className={className} controls ref={videoRef} style={style} />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Info, TriangleAlert } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Info, Stethoscope, TriangleAlert } from 'lucide-react';
|
||||
import { Button, Card, Dialog, IconButton, Spinner, Tag } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
@@ -513,6 +513,14 @@ export function MovieDetailScreen({ id }: { id: number }) {
|
||||
<Button onClick={() => setInfoOpen(true)} size="sm" startIcon={<Info size={14} />} variant="secondary">
|
||||
Media Info
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigateToPath(`/app/troubleshooting/playback?mediaItem=${id}`)}
|
||||
size="sm"
|
||||
startIcon={<Stethoscope size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Troubleshoot Playback
|
||||
</Button>
|
||||
{/* Add-to-collection / add-to-playlist mutations are out of scope here; tracked by #153 / #155. */}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlaybackTroubleshootingScreen } from './PlaybackTroubleshootingScreen';
|
||||
|
||||
// hls.js is mocked wholesale so jsdom never touches a real MediaSource. The shared spies let tests
|
||||
// assert the manifest URL passed to loadSource.
|
||||
const hlsMock = vi.hoisted(() => ({
|
||||
loadSource: vi.fn(),
|
||||
attachMedia: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
isSupported: true
|
||||
}));
|
||||
|
||||
vi.mock('hls.js', () => {
|
||||
class MockHls {
|
||||
static isSupported() {
|
||||
return hlsMock.isSupported;
|
||||
}
|
||||
static Events = { MANIFEST_PARSED: 'hlsManifestParsed' };
|
||||
loadSource = hlsMock.loadSource;
|
||||
attachMedia = hlsMock.attachMedia;
|
||||
on = hlsMock.on;
|
||||
destroy = hlsMock.destroy;
|
||||
}
|
||||
return { default: MockHls };
|
||||
});
|
||||
|
||||
interface Status {
|
||||
state: string;
|
||||
exitCode: null | number;
|
||||
speed: null | number;
|
||||
logs: null | string;
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const IDLE: Status = { state: 'idle', exitCode: null, speed: null, logs: null };
|
||||
|
||||
// A fetch stub keyed by URL. `statusRef.current` lets a test flip the polled status over time.
|
||||
function installFetch(statusRef: { current: Status }) {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.startsWith('/api/ffmpeg/profiles')) {
|
||||
return Promise.resolve(json([{ id: 1, name: 'Default' }, { id: 2, name: 'Hardware' }]));
|
||||
}
|
||||
if (url.startsWith('/api/troubleshoot/playback/stream-selectors')) {
|
||||
return Promise.resolve(json(['selector-a', 'selector-b']));
|
||||
}
|
||||
if (url.startsWith('/api/watermarks')) {
|
||||
return Promise.resolve(json([{ id: 10, name: 'Logo Watermark' }]));
|
||||
}
|
||||
if (url.startsWith('/api/graphics-elements')) {
|
||||
return Promise.resolve(json([{ id: 20, name: 'Lower Third' }]));
|
||||
}
|
||||
if (url.includes('/api/troubleshoot/playback/subtitles/')) {
|
||||
return Promise.resolve(json([{ id: 3, language: 'eng', title: 'English', codec: 'subrip' }]));
|
||||
}
|
||||
if (url.startsWith('/api/troubleshoot/playback/status')) {
|
||||
return Promise.resolve(json(statusRef.current));
|
||||
}
|
||||
if (url.includes('/api/media-items/')) {
|
||||
return Promise.resolve(
|
||||
json({ id: 5, title: 'Test Movie', kind: 'Movie', duration: '00:40:00', streams: [], chapters: [] })
|
||||
);
|
||||
}
|
||||
if (url.startsWith('/api/channels/')) {
|
||||
// Pin the RUNTIME shape, not the OpenAPI-spec shape: the server serializes with Newtonsoft,
|
||||
// so the profile key is "ffmpegProfileId" (the generated types wrongly say "fFmpegProfileId"
|
||||
// — see #198). streamSelectorMode/streamSelector match the spec (no leading acronym).
|
||||
return Promise.resolve(
|
||||
json({ id: 7, name: 'Channel Seven', ffmpegProfileId: 2, streamSelectorMode: 'Default', streamSelector: null })
|
||||
);
|
||||
}
|
||||
return Promise.resolve(json({}, 404));
|
||||
});
|
||||
}
|
||||
|
||||
function setLocation(searchAndPath: string) {
|
||||
window.history.replaceState({}, '', searchAndPath);
|
||||
}
|
||||
|
||||
describe('PlaybackTroubleshootingScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
hlsMock.loadSource.mockClear();
|
||||
hlsMock.attachMedia.mockClear();
|
||||
hlsMock.on.mockClear();
|
||||
hlsMock.destroy.mockClear();
|
||||
hlsMock.isSupported = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setLocation('/app/troubleshooting/playback');
|
||||
});
|
||||
|
||||
it('shows a friendly empty state when no channel or media item is supplied', async () => {
|
||||
setLocation('/app/troubleshooting/playback');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Playback troubleshooting starts from a channel or a media item/i)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders media-mode pickers and gates subtitle vs stream-selector mutual exclusion', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
|
||||
expect(await screen.findByText(/Movie Settings — Test Movie/i)).toBeTruthy();
|
||||
|
||||
// Media-only fields present.
|
||||
expect(screen.getByText('Subtitle')).toBeTruthy();
|
||||
expect(screen.getByText('Watermarks')).toBeTruthy();
|
||||
expect(screen.getByText('Graphics Elements')).toBeTruthy();
|
||||
expect(screen.getByText('Seek Seconds')).toBeTruthy();
|
||||
// Channel-only field absent.
|
||||
expect(screen.queryByText('Date and Time')).toBeNull();
|
||||
|
||||
// Choosing a stream selector disables the subtitle select (mutual exclusion).
|
||||
const [, streamSelectorSelect] = screen.getAllByRole('combobox');
|
||||
const subtitleSelect = screen.getAllByRole('combobox')[2];
|
||||
expect((subtitleSelect as HTMLSelectElement).disabled).toBe(false);
|
||||
fireEvent.change(streamSelectorSelect, { target: { value: 'selector-a' } });
|
||||
await waitFor(() => expect((screen.getAllByRole('combobox')[2] as HTMLSelectElement).disabled).toBe(true));
|
||||
});
|
||||
|
||||
it('renders channel mode with the channel profile preselected and hides media-only fields', async () => {
|
||||
setLocation('/app/troubleshooting/playback?channel=7');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
|
||||
expect(await screen.findByText(/Channel Settings — Channel Seven/i)).toBeTruthy();
|
||||
|
||||
// Channel's FFmpeg profile (id 2 = Hardware) is preselected.
|
||||
const profileSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
|
||||
expect(profileSelect.value).toBe('2');
|
||||
|
||||
// Channel-only field present; media-only fields absent.
|
||||
expect(screen.getByText('Date and Time')).toBeTruthy();
|
||||
expect(screen.queryByText('Subtitle')).toBeNull();
|
||||
expect(screen.queryByText('Seek Seconds')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends the channel profile id (not 0) in the channel-mode playback URL', async () => {
|
||||
setLocation('/app/troubleshooting/playback?channel=7');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
await screen.findByText(/Channel Settings — Channel Seven/i);
|
||||
|
||||
// Play stays gated until a start datetime is chosen.
|
||||
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const dateTimeInput = document.querySelector('input[type="datetime-local"]');
|
||||
expect(dateTimeInput).not.toBeNull();
|
||||
fireEvent.change(dateTimeInput as HTMLInputElement, { target: { value: '2026-07-09T20:00' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
|
||||
const params = new URLSearchParams(String(hlsMock.loadSource.mock.calls[0][0]).split('?')[1]);
|
||||
// The regression this pins: the runtime channel JSON key is ffmpegProfileId (#198); reading the
|
||||
// spec-cased fFmpegProfileId left the profile null and the URL fell back to ffmpegProfile=0.
|
||||
expect(params.get('ffmpegProfile')).toBe('2');
|
||||
expect(params.get('channel')).toBe('7');
|
||||
expect(params.get('start')).toBe(new Date('2026-07-09T20:00').toISOString());
|
||||
expect(params.get('mediaItem')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds the correct playback.m3u8 query on Play and starts polling', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());
|
||||
|
||||
// After Play, the status endpoint reports running.
|
||||
statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
|
||||
const url = String(hlsMock.loadSource.mock.calls[0][0]);
|
||||
expect(url.startsWith('/api/troubleshoot/playback.m3u8?')).toBe(true);
|
||||
const params = new URLSearchParams(url.split('?')[1]);
|
||||
expect(params.get('ffmpegProfile')).toBe('1');
|
||||
expect(params.get('streamingMode')).toBe('4');
|
||||
expect(params.get('mediaItem')).toBe('5');
|
||||
expect(params.get('seekSeconds')).toBe('1200'); // 40 min / 2
|
||||
expect(params.get('channel')).toBeNull();
|
||||
|
||||
// Polling picks up the running status → Play becomes disabled.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() => expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces logs and a speed badge when polling transitions running → completed', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());
|
||||
|
||||
statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() => expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true));
|
||||
|
||||
// Flip to completed with logs + speed.
|
||||
statusRef.current = { state: 'completed', exitCode: 0, speed: 1.5, logs: 'ffmpeg log line' };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByText(/Speed: 1.5x/)).toBeTruthy());
|
||||
expect((screen.getByDisplayValue('ffmpeg log line') as HTMLTextAreaElement).value).toBe('ffmpeg log line');
|
||||
// Success notice + Play re-enabled.
|
||||
expect(screen.getByText(/exited successfully/i)).toBeTruthy();
|
||||
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-issues the manifest request on a repeat Play with unchanged settings', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());
|
||||
|
||||
// First play through to completion.
|
||||
statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000); // observe running
|
||||
statusRef.current = { state: 'completed', exitCode: 0, speed: 1.1, logs: 'done' };
|
||||
await vi.advanceTimersByTimeAsync(2000); // observe completed
|
||||
await vi.waitFor(() =>
|
||||
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
|
||||
// Repeat Play with untouched settings: identical URL, but the manifest request MUST fire
|
||||
// again (the GET is what starts a session server-side).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
|
||||
expect(hlsMock.loadSource.mock.calls[1][0]).toBe(hlsMock.loadSource.mock.calls[0][0]);
|
||||
expect(hlsMock.destroy).toHaveBeenCalled(); // previous instance torn down before re-attach
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a stale settled status while starting and only settles after observing running', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5');
|
||||
// The store still holds the PREVIOUS session's result when Play is clicked.
|
||||
const statusRef = {
|
||||
current: { state: 'completed', exitCode: 0, speed: 2, logs: 'old logs' } as Status
|
||||
};
|
||||
installFetch(statusRef);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());
|
||||
// Mount poll surfaces the previous session's logs (post-restart log recovery) without a toast.
|
||||
await vi.waitFor(() => expect(screen.getByDisplayValue('old logs')).toBeTruthy());
|
||||
expect(screen.queryByText(/exited successfully/i)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
|
||||
// 'starting': Play disabled immediately, indicator shown, old logs cleared.
|
||||
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText('Starting…')).toBeTruthy();
|
||||
expect(screen.queryByDisplayValue('old logs')).toBeNull();
|
||||
|
||||
// A poll returning the STALE completed result must be ignored — no toast, still gated.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(screen.queryByText(/exited successfully/i)).toBeNull();
|
||||
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByDisplayValue('old logs')).toBeNull();
|
||||
|
||||
// Server reports running → transcoding phase.
|
||||
statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() => expect(screen.getByText('Transcoding…')).toBeTruthy());
|
||||
|
||||
// Real completion → toast + new logs + Play re-enabled.
|
||||
statusRef.current = { state: 'completed', exitCode: 0, speed: 1.2, logs: 'new logs' };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() => expect(screen.getByText(/exited successfully/i)).toBeTruthy());
|
||||
expect(screen.getByDisplayValue('new logs')).toBeTruthy();
|
||||
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('gives up with an error when the session never reports running', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
|
||||
// Status stays idle past the 30s starting timeout → error notice, Play re-enabled.
|
||||
await vi.advanceTimersByTimeAsync(32_000);
|
||||
await vi.waitFor(() => expect(screen.getByText(/did not start within 30 seconds/i)).toBeTruthy());
|
||||
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the archive and media-sample downloads, gated while a session is active', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());
|
||||
|
||||
// Download Media Sample is available while idle.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Download Media Sample/i }));
|
||||
expect(openSpy).toHaveBeenCalledWith('/api/troubleshoot/playback/sample/5');
|
||||
|
||||
// Both downloads are gated while a session is starting/running.
|
||||
statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
expect((screen.getByRole('button', { name: /Download Results/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole('button', { name: /Download Media Sample/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
// Run to completion → Download Results enabled (hasPlayed && not busy).
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
statusRef.current = { state: 'completed', exitCode: 0, speed: 1, logs: 'done' };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() =>
|
||||
expect((screen.getByRole('button', { name: /Download Results/i }) as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Download Results/i }));
|
||||
expect(openSpy).toHaveBeenCalledWith('/api/troubleshoot/playback/archive');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses media mode when both mediaItem and channel params are present', async () => {
|
||||
setLocation('/app/troubleshooting/playback?mediaItem=5&channel=7');
|
||||
const statusRef = { current: IDLE };
|
||||
installFetch(statusRef);
|
||||
|
||||
render(<PlaybackTroubleshootingScreen />);
|
||||
|
||||
expect(await screen.findByText(/Movie Settings — Test Movie/i)).toBeTruthy();
|
||||
expect(screen.queryByText('Date and Time')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,704 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Download, DownloadCloud, PlayCircle, Stethoscope, TriangleAlert } from 'lucide-react';
|
||||
import { Button, Card, Checkbox, Input, Select, Spinner, Toast } from '../components';
|
||||
import {
|
||||
getChannelById,
|
||||
getFFmpegProfiles,
|
||||
getGraphicsElements,
|
||||
getMediaItemInfo,
|
||||
getTroubleshootingPlaybackStatus,
|
||||
getTroubleshootingStreamSelectors,
|
||||
getTroubleshootingSubtitles,
|
||||
getWatermarks,
|
||||
messageFromTroubleshootError,
|
||||
type Channel,
|
||||
type FFmpegProfile,
|
||||
type GraphicsElement,
|
||||
type MediaItemInfo,
|
||||
type TroubleshootingPlaybackStatus,
|
||||
type TroubleshootingSubtitle,
|
||||
type Watermark
|
||||
} from '../api';
|
||||
import { HlsPlayer } from '../media/HlsPlayer';
|
||||
import { parseDurationSeconds } from '../media/mediaKinds';
|
||||
|
||||
// StreamingMode.HttpLiveStreamingSegmenter — mirrors PlaybackTroubleshooting.razor, which always
|
||||
// requests the segmenter mode for troubleshooting playback.
|
||||
const STREAMING_MODE_HLS_SEGMENTER = 4;
|
||||
const STATUS_POLL_MS = 2000;
|
||||
// How long after Play we keep waiting for the server to report 'running' before giving up.
|
||||
// PrepareTroubleshootingPlayback runs several DB queries before it takes the playback lock, so the
|
||||
// first polls after Play can still see the PREVIOUS session's settled result — those are ignored
|
||||
// during the 'starting' phase (see applyStatus) until this timeout elapses.
|
||||
const STARTING_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Play lifecycle owned by this screen (the server only knows idle/running/completed/failed):
|
||||
// idle → (Play) → starting → (server reports running) → running → (server settles) → idle.
|
||||
type PlayPhase = 'idle' | 'starting' | 'running';
|
||||
|
||||
type Mode =
|
||||
| { kind: 'media'; mediaItemId: number }
|
||||
| { kind: 'channel'; channelId: number }
|
||||
| { kind: 'empty' };
|
||||
|
||||
function parseMode(search: string): Mode {
|
||||
const params = new URLSearchParams(search);
|
||||
const rawMedia = params.get('mediaItem');
|
||||
const rawChannel = params.get('channel');
|
||||
const mediaItemId = rawMedia ? Number(rawMedia) : NaN;
|
||||
const channelId = rawChannel ? Number(rawChannel) : NaN;
|
||||
const hasMedia = Number.isInteger(mediaItemId) && mediaItemId > 0;
|
||||
const hasChannel = Number.isInteger(channelId) && channelId > 0;
|
||||
|
||||
// mediaItem takes precedence when both are present (matching Blazor's OnParametersSetAsync,
|
||||
// which checks MediaItemId first); neither param is the empty state.
|
||||
if (hasMedia) {
|
||||
return { kind: 'media', mediaItemId };
|
||||
}
|
||||
if (hasChannel) {
|
||||
return { kind: 'channel', channelId };
|
||||
}
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
// Insert a space at lowercase→uppercase boundaries, matching the Blazor page's kind humanization
|
||||
// (e.g. "MusicVideo" → "Music Video", "RemoteStream" → "Remote Stream").
|
||||
function humanizeKind(kind: string): string {
|
||||
return kind.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
// Speed thresholds copied from PlaybackTroubleshooting.razor GetSpeedClass: <0.9 slow (red),
|
||||
// >1.1 fast enough (green), otherwise marginal (amber).
|
||||
function speedColor(speed: number): string {
|
||||
if (speed < 0.9) {
|
||||
return 'var(--status-error)';
|
||||
}
|
||||
if (speed > 1.1) {
|
||||
return 'var(--status-ok)';
|
||||
}
|
||||
return 'var(--status-warn)';
|
||||
}
|
||||
|
||||
interface LoadedData {
|
||||
profiles: FFmpegProfile[];
|
||||
streamSelectors: string[];
|
||||
watermarks: Watermark[];
|
||||
graphicsElements: GraphicsElement[];
|
||||
// media mode
|
||||
info: MediaItemInfo | null;
|
||||
rawKind: null | string;
|
||||
subtitles: TroubleshootingSubtitle[];
|
||||
// channel mode
|
||||
channel: Channel | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; error: string }
|
||||
| { status: 'success'; data: LoadedData };
|
||||
|
||||
interface FormState {
|
||||
ffmpegProfileId: null | number;
|
||||
streamSelector: string;
|
||||
subtitleId: null | number;
|
||||
watermarkIds: number[];
|
||||
graphicsElementIds: number[];
|
||||
startFromBeginning: boolean;
|
||||
seekSeconds: number;
|
||||
startValue: string;
|
||||
}
|
||||
|
||||
function FormRow({ children, label }: { children: ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">{label}</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flex: '0 0 340px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaybackTroubleshootingScreen() {
|
||||
const [search, setSearch] = useState(() => window.location.search);
|
||||
const mode = useMemo(() => parseMode(search), [search]);
|
||||
|
||||
const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
|
||||
const [form, setForm] = useState<FormState>({
|
||||
ffmpegProfileId: null,
|
||||
streamSelector: '',
|
||||
subtitleId: null,
|
||||
watermarkIds: [],
|
||||
graphicsElementIds: [],
|
||||
startFromBeginning: false,
|
||||
seekSeconds: 0,
|
||||
startValue: ''
|
||||
});
|
||||
const [playerSrc, setPlayerSrc] = useState<null | string>(null);
|
||||
// Incremented on every Play so HlsPlayer re-attaches even when the manifest URL is unchanged —
|
||||
// the manifest GET is what starts a session server-side, so a repeat Play with identical settings
|
||||
// must still issue a new request.
|
||||
const [playToken, setPlayToken] = useState(0);
|
||||
const [playbackStatus, setPlaybackStatus] = useState<null | TroubleshootingPlaybackStatus>(null);
|
||||
const [playPhase, setPlayPhase] = useState<PlayPhase>('idle');
|
||||
const [hasPlayed, setHasPlayed] = useState(false);
|
||||
const [notice, setNotice] = useState<null | { tone: 'error' | 'ok' | 'warn'; message: string }>(null);
|
||||
|
||||
const activeRef = useRef(true);
|
||||
const loadSeqRef = useRef(0);
|
||||
const pollRef = useRef<null | number>(null);
|
||||
// Mirror of playPhase readable from the polling interval callback without re-creating it.
|
||||
const phaseRef = useRef<PlayPhase>('idle');
|
||||
const startingSinceRef = useRef(0);
|
||||
|
||||
const setPhase = useCallback((phase: PlayPhase) => {
|
||||
phaseRef.current = phase;
|
||||
setPlayPhase(phase);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// popstate is an external event (not an effect body), so resetting to the loading state here is
|
||||
// allowed by the react-hooks set-state-in-effect rule and mirrors MediaRouteScreen's tracking.
|
||||
const onPopState = () => {
|
||||
setSearch(window.location.search);
|
||||
setLoadState({ status: 'loading' });
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current !== null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Apply a freshly-fetched status through the play-phase machine:
|
||||
// - 'starting': ignore settled/idle states entirely — the server hasn't taken the playback lock
|
||||
// yet, so these are the PREVIOUS session's stale result (applying them would show a spurious
|
||||
// completion over a session that is still spinning up). Promote to 'running' once the server
|
||||
// reports it; give up with an error after STARTING_TIMEOUT_MS.
|
||||
// - 'running': apply; surface the completion notice exactly once on the settled transition.
|
||||
const applyStatus = useCallback(
|
||||
(status: TroubleshootingPlaybackStatus) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (phaseRef.current === 'starting') {
|
||||
if (status.state === 'running') {
|
||||
setPhase('running');
|
||||
setPlaybackStatus(status);
|
||||
} else if (Date.now() - startingSinceRef.current > STARTING_TIMEOUT_MS) {
|
||||
setPhase('idle');
|
||||
stopPolling();
|
||||
setNotice({
|
||||
tone: 'error',
|
||||
message: 'Troubleshooting playback did not start within 30 seconds'
|
||||
});
|
||||
}
|
||||
// Otherwise: stale settled/idle result during startup — keep waiting.
|
||||
return;
|
||||
}
|
||||
|
||||
setPlaybackStatus(status);
|
||||
|
||||
if (phaseRef.current === 'running' && (status.state === 'completed' || status.state === 'failed')) {
|
||||
setPhase('idle');
|
||||
stopPolling();
|
||||
if (status.exitCode === 0) {
|
||||
setNotice({ tone: 'ok', message: 'FFmpeg troubleshooting process exited successfully' });
|
||||
} else {
|
||||
setNotice({
|
||||
tone: 'warn',
|
||||
message: `FFmpeg troubleshooting process exited with code ${status.exitCode ?? 'unknown'}`
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[setPhase, stopPolling]
|
||||
);
|
||||
|
||||
const pollOnce = useCallback(() => {
|
||||
getTroubleshootingPlaybackStatus()
|
||||
.then((status) => applyStatus(status))
|
||||
.catch(() => {
|
||||
// Ignore transient polling failures; the next tick retries.
|
||||
});
|
||||
}, [applyStatus]);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(() => pollOnce(), STATUS_POLL_MS);
|
||||
}, [pollOnce, stopPolling]);
|
||||
|
||||
// Load form data whenever the mode changes. State transitions happen only in the promise
|
||||
// callbacks (never synchronously in the effect body) per docs/spa-conventions.md §3.
|
||||
const load = useCallback(() => {
|
||||
if (mode.kind === 'empty') {
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++loadSeqRef.current;
|
||||
const base = Promise.all([
|
||||
getFFmpegProfiles(),
|
||||
getTroubleshootingStreamSelectors(),
|
||||
getWatermarks(),
|
||||
// refresh=true re-syncs on-disk graphics element definitions first, matching Blazor's
|
||||
// RefreshGraphicsElements-before-GetAllGraphicsElements.
|
||||
getGraphicsElements(true)
|
||||
]);
|
||||
|
||||
base
|
||||
.then(async ([profiles, streamSelectors, watermarks, graphicsElements]) => {
|
||||
if (mode.kind === 'media') {
|
||||
const [info, subtitles] = await Promise.all([
|
||||
getMediaItemInfo(mode.mediaItemId),
|
||||
getTroubleshootingSubtitles(mode.mediaItemId)
|
||||
]);
|
||||
return {
|
||||
profiles,
|
||||
streamSelectors,
|
||||
watermarks,
|
||||
graphicsElements,
|
||||
info,
|
||||
rawKind: info.kind,
|
||||
subtitles,
|
||||
channel: null,
|
||||
title: info.title
|
||||
} satisfies LoadedData;
|
||||
}
|
||||
|
||||
const channel = await getChannelById(mode.channelId);
|
||||
return {
|
||||
profiles,
|
||||
streamSelectors,
|
||||
watermarks,
|
||||
graphicsElements,
|
||||
info: null,
|
||||
rawKind: null,
|
||||
subtitles: [],
|
||||
channel,
|
||||
title: channel.name ?? ''
|
||||
} satisfies LoadedData;
|
||||
})
|
||||
.then((data) => {
|
||||
if (!activeRef.current || seq !== loadSeqRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive initial form defaults (mirrors the Blazor OnParametersSetAsync / LoadMediaItem /
|
||||
// LoadChannel logic).
|
||||
let defaultProfileId: null | number = data.profiles[0]?.id ?? null;
|
||||
if (mode.kind === 'channel' && data.channel) {
|
||||
// Runtime JSON is Newtonsoft-cased ("ffmpegProfileId"); generated types say
|
||||
// "fFmpegProfileId" — see #198. Read the runtime key first, fall back to the spec key,
|
||||
// then to the first profile; never let this silently become 0.
|
||||
// (streamSelectorMode / streamSelector below have no leading acronym, so their runtime
|
||||
// keys match the generated types — verified against a live GET /api/channels/{id}.)
|
||||
const rawChannel = data.channel as typeof data.channel & { ffmpegProfileId?: null | number };
|
||||
defaultProfileId = rawChannel.ffmpegProfileId ?? data.channel.fFmpegProfileId ?? defaultProfileId;
|
||||
}
|
||||
|
||||
const isRemoteStream = (data.rawKind ?? '').toLowerCase() === 'remotestream';
|
||||
const durationSeconds = parseDurationSeconds(data.info?.duration) ?? 0;
|
||||
|
||||
let streamSelector = '';
|
||||
if (mode.kind === 'channel' && data.channel?.streamSelectorMode === 'Custom') {
|
||||
streamSelector = data.channel.streamSelector ?? '';
|
||||
}
|
||||
|
||||
setForm({
|
||||
ffmpegProfileId: defaultProfileId,
|
||||
streamSelector,
|
||||
subtitleId: null,
|
||||
watermarkIds: [],
|
||||
graphicsElementIds: [],
|
||||
// Start From Beginning defaults on for RemoteStream (and its checkbox is disabled).
|
||||
startFromBeginning: isRemoteStream,
|
||||
seekSeconds: isRemoteStream ? 0 : Math.round(durationSeconds / 2),
|
||||
startValue: ''
|
||||
});
|
||||
setLoadState({ status: 'success', data });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current && seq === loadSeqRef.current) {
|
||||
setLoadState({ status: 'error', error: messageFromTroubleshootError(error, 'Unable to load troubleshooting options') });
|
||||
}
|
||||
});
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// On mount, reflect any session already running (started here or elsewhere) so Play stays gated.
|
||||
// Intentional side effect: after an app restart the server reports state=idle with the previous
|
||||
// session's logs.txt tail still attached — we apply that status as-is so those logs are
|
||||
// recoverable from the UI (post-restart log recovery), matching what the status endpoint reports.
|
||||
useEffect(() => {
|
||||
getTroubleshootingPlaybackStatus()
|
||||
.then((status) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setPlaybackStatus(status);
|
||||
if (status.state === 'running') {
|
||||
setPhase('running');
|
||||
startPolling();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// No status yet is fine.
|
||||
});
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [setPhase, startPolling, stopPolling]);
|
||||
|
||||
// Busy = a session this screen is aware of is starting or transcoding; gates Play + downloads.
|
||||
const isBusy = playPhase !== 'idle';
|
||||
|
||||
// Build the playback.m3u8 query string, mirroring PlaybackTroubleshooting.razor PreviewChannel.
|
||||
const buildPlaybackUrl = useCallback((): null | string => {
|
||||
// Sending ffmpegProfile=0 is always wrong (no such profile) — refuse to build a URL without one.
|
||||
if (form.ffmpegProfileId == null) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('ffmpegProfile', String(form.ffmpegProfileId));
|
||||
params.set('streamingMode', String(STREAMING_MODE_HLS_SEGMENTER));
|
||||
|
||||
if (mode.kind === 'channel') {
|
||||
if (form.startValue === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(form.startValue);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
params.set('channel', String(mode.channelId));
|
||||
params.set('start', parsed.toISOString());
|
||||
} else if (mode.kind === 'media') {
|
||||
params.set('mediaItem', String(mode.mediaItemId));
|
||||
params.set('seekSeconds', String(form.seekSeconds));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const id of form.watermarkIds) {
|
||||
params.append('watermark', String(id));
|
||||
}
|
||||
for (const id of form.graphicsElementIds) {
|
||||
params.append('graphicsElement', String(id));
|
||||
}
|
||||
|
||||
// Stream selector and subtitle are mutually exclusive (selector wins).
|
||||
if (form.streamSelector !== '') {
|
||||
params.set('streamSelector', form.streamSelector);
|
||||
} else if (form.subtitleId != null) {
|
||||
params.set('subtitleId', String(form.subtitleId));
|
||||
}
|
||||
|
||||
return `/api/troubleshoot/playback.m3u8?${params.toString()}`;
|
||||
}, [form, mode]);
|
||||
|
||||
const onPlay = () => {
|
||||
const url = buildPlaybackUrl();
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
setNotice(null);
|
||||
setPlaybackStatus((current) => (current ? { ...current, logs: null, speed: null } : current));
|
||||
setPhase('starting');
|
||||
startingSinceRef.current = Date.now();
|
||||
setHasPlayed(true);
|
||||
setPlayerSrc(url);
|
||||
// Bump the token so HlsPlayer re-attaches even when the URL is identical to the previous play.
|
||||
setPlayToken((current) => current + 1);
|
||||
startPolling();
|
||||
};
|
||||
|
||||
if (mode.kind === 'empty') {
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<Card padded>
|
||||
<div className="ctv-collections-empty" role="status">
|
||||
<Stethoscope aria-hidden="true" size={22} />
|
||||
<p style={{ marginTop: 12 }}>Playback troubleshooting starts from a channel or a media item.</p>
|
||||
<p className="ctv-settings-row-help">
|
||||
Open the Channels table and use a channel’s Troubleshoot action, or open a movie’s detail
|
||||
page and choose Troubleshoot Playback.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState.status === 'loading') {
|
||||
return (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={18} />
|
||||
<span>Loading troubleshooting options…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState.status === 'error') {
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{loadState.error}</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoadState({ status: 'loading' });
|
||||
load();
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = loadState;
|
||||
const isChannelMode = mode.kind === 'channel';
|
||||
const isRemoteStream = (data.rawKind ?? '').toLowerCase() === 'remotestream';
|
||||
const kindLabel = isChannelMode ? 'Channel' : data.rawKind ? humanizeKind(data.rawKind) : 'Playback';
|
||||
const settingsTitle = `${kindLabel} Settings${data.title ? ` — ${data.title}` : ''}`;
|
||||
|
||||
const profileOptions = data.profiles.map((profile) => ({
|
||||
value: String(profile.id),
|
||||
label: profile.name ?? `Profile ${profile.id}`
|
||||
}));
|
||||
|
||||
const streamSelectorOptions = [
|
||||
{ value: '', label: '(none)' },
|
||||
...data.streamSelectors.map((selector) => ({ value: selector, label: selector }))
|
||||
];
|
||||
|
||||
const subtitleOptions = [
|
||||
{ value: '', label: '(none)' },
|
||||
...data.subtitles.map((subtitle) => ({
|
||||
value: String(subtitle.id),
|
||||
label: `${subtitle.id}: ${subtitle.language ?? ''} - ${subtitle.title ?? ''} (${subtitle.codec ?? ''})`
|
||||
}))
|
||||
];
|
||||
|
||||
const subtitleDisabled = form.streamSelector !== '';
|
||||
const downloadsDisabled = isBusy;
|
||||
const speed = playbackStatus?.speed ?? null;
|
||||
const logs = playbackStatus?.logs ?? null;
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<Button
|
||||
disabled={!hasPlayed || downloadsDisabled}
|
||||
onClick={() => window.open('/api/troubleshoot/playback/archive')}
|
||||
size="sm"
|
||||
startIcon={<Download aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Download Results
|
||||
</Button>
|
||||
{mode.kind === 'media' && (
|
||||
<Button
|
||||
disabled={downloadsDisabled}
|
||||
onClick={() => window.open(`/api/troubleshoot/playback/sample/${mode.mediaItemId}`)}
|
||||
size="sm"
|
||||
startIcon={<DownloadCloud aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Download Media Sample
|
||||
</Button>
|
||||
)}
|
||||
<span className="ctv-channels-spacer" />
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<Toast
|
||||
message={notice.message}
|
||||
onClose={() => setNotice(null)}
|
||||
tone={notice.tone}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card padded subtitle={isChannelMode ? 'Preview a channel at a point in time' : 'Preview a media item'} title={settingsTitle}>
|
||||
<FormRow label="FFmpeg Profile">
|
||||
<Select
|
||||
onChange={(event) => setForm((current) => ({ ...current, ffmpegProfileId: Number(event.target.value) }))}
|
||||
options={profileOptions}
|
||||
value={form.ffmpegProfileId == null ? '' : String(form.ffmpegProfileId)}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Stream Selector">
|
||||
<Select
|
||||
disabled={data.streamSelectors.length === 0}
|
||||
onChange={(event) => setForm((current) => ({ ...current, streamSelector: event.target.value }))}
|
||||
options={streamSelectorOptions}
|
||||
value={form.streamSelector}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
{isChannelMode ? (
|
||||
<FormRow label="Date and Time">
|
||||
<Input
|
||||
onChange={(event) => setForm((current) => ({ ...current, startValue: event.target.value }))}
|
||||
type="datetime-local"
|
||||
value={form.startValue}
|
||||
/>
|
||||
</FormRow>
|
||||
) : (
|
||||
<>
|
||||
<FormRow label="Subtitle">
|
||||
<Select
|
||||
disabled={subtitleDisabled}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
subtitleId: event.target.value === '' ? null : Number(event.target.value)
|
||||
}))
|
||||
}
|
||||
options={subtitleOptions}
|
||||
value={form.subtitleId == null ? '' : String(form.subtitleId)}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Watermarks">
|
||||
<CheckboxList
|
||||
items={data.watermarks.map((watermark) => ({ id: watermark.id, name: watermark.name ?? `#${watermark.id}` }))}
|
||||
onToggle={(id) =>
|
||||
setForm((current) => ({ ...current, watermarkIds: toggle(current.watermarkIds, id) }))
|
||||
}
|
||||
selected={form.watermarkIds}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Graphics Elements">
|
||||
<CheckboxList
|
||||
items={data.graphicsElements.map((element) => ({ id: element.id, name: element.name ?? `#${element.id}` }))}
|
||||
onToggle={(id) =>
|
||||
setForm((current) => ({ ...current, graphicsElementIds: toggle(current.graphicsElementIds, id) }))
|
||||
}
|
||||
selected={form.graphicsElementIds}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Start From Beginning">
|
||||
<Checkbox
|
||||
checked={form.startFromBeginning}
|
||||
disabled={isRemoteStream}
|
||||
label="Start from the beginning"
|
||||
onChange={(next) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
startFromBeginning: next,
|
||||
seekSeconds: next ? 0 : Math.round((parseDurationSeconds(data.info?.duration) ?? 0) / 2)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Seek Seconds">
|
||||
<Input
|
||||
disabled={form.startFromBeginning}
|
||||
onChange={(event) => setForm((current) => ({ ...current, seekSeconds: Number(event.target.value) || 0 }))}
|
||||
type="number"
|
||||
value={String(form.seekSeconds)}
|
||||
/>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padded subtitle="Live segmenter output" title="Preview">
|
||||
<Button
|
||||
disabled={isBusy || form.ffmpegProfileId == null || (isChannelMode && form.startValue === '')}
|
||||
onClick={onPlay}
|
||||
startIcon={<PlayCircle aria-hidden="true" size={15} />}
|
||||
variant="primary"
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
{isBusy && (
|
||||
<div className="ctv-collections-loading" role="status" style={{ marginTop: 12 }}>
|
||||
<Spinner size={16} />
|
||||
<span>{playPhase === 'starting' ? 'Starting…' : 'Transcoding…'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16, maxWidth: 720 }}>
|
||||
<HlsPlayer playToken={playToken} src={playerSrc} style={{ aspectRatio: '16 / 9', width: '100%', background: '#000' }} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padded
|
||||
title={
|
||||
<span>
|
||||
Logs{' '}
|
||||
{speed != null && (
|
||||
<span style={{ color: speedColor(speed) }}>(Speed: {speed}x)</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
className="ctv-troubleshoot-pre"
|
||||
readOnly
|
||||
rows={20}
|
||||
style={{ width: '100%', fontFamily: 'var(--font-mono)', resize: 'vertical' }}
|
||||
value={logs ?? ''}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggle(ids: number[], id: number): number[] {
|
||||
return ids.includes(id) ? ids.filter((existing) => existing !== id) : [...ids, id];
|
||||
}
|
||||
|
||||
function CheckboxList({
|
||||
items,
|
||||
onToggle,
|
||||
selected
|
||||
}: {
|
||||
items: Array<{ id: number; name: string }>;
|
||||
onToggle: (id: number) => void;
|
||||
selected: number[];
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <div className="ctv-settings-row-help">None available.</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((item) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(item.id)}
|
||||
key={item.id}
|
||||
label={item.name}
|
||||
onChange={() => onToggle(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user