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

This commit was merged in pull request #199.
This commit is contained in:
2026-07-09 06:21:03 +00:00
30 changed files with 2045 additions and 18 deletions
@@ -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);
}
}
+1
View File
@@ -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);
+221
View File
@@ -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",
+5 -4
View File
@@ -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
+31
View File
@@ -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).
+4 -1
View File
@@ -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
+17
View File
@@ -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.
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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 />;
}
+12
View File
@@ -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;
+29
View File
@@ -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');
});
});
+5 -2
View File
@@ -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[]> {
+43 -1
View File
@@ -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');
});
});
+20
View File
@@ -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 },
+96
View File
@@ -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} />;
}
+9 -1
View File
@@ -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 channels Troubleshoot action, or open a movies 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>
);
}