diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 09c470884..94daedf0d 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -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 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); diff --git a/ErsatzTV.Application/Troubleshooting/RecordTroubleshootingPlaybackStatusHandler.cs b/ErsatzTV.Application/Troubleshooting/RecordTroubleshootingPlaybackStatusHandler.cs new file mode 100644 index 000000000..47438511e --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/RecordTroubleshootingPlaybackStatusHandler.cs @@ -0,0 +1,14 @@ +using ErsatzTV.Core.Interfaces.Troubleshooting; +using ErsatzTV.Core.Notifications; + +namespace ErsatzTV.Application.Troubleshooting; + +public class RecordTroubleshootingPlaybackStatusHandler(ITroubleshootingPlaybackStatusStore statusStore) + : INotificationHandler +{ + public Task Handle(PlaybackTroubleshootingCompletedNotification notification, CancellationToken cancellationToken) + { + statusStore.RecordCompletion(notification.ExitCode, notification.MaybeSpeed); + return Task.CompletedTask; + } +} diff --git a/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingPlaybackStatusResponseModel.cs b/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingPlaybackStatusResponseModel.cs new file mode 100644 index 000000000..3c49c4de7 --- /dev/null +++ b/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingPlaybackStatusResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingSubtitleResponseModel.cs b/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingSubtitleResponseModel.cs new file mode 100644 index 000000000..3a5583775 --- /dev/null +++ b/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingSubtitleResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Interfaces/Troubleshooting/ITroubleshootingPlaybackStatusStore.cs b/ErsatzTV.Core/Interfaces/Troubleshooting/ITroubleshootingPlaybackStatusStore.cs new file mode 100644 index 000000000..0bb6d1a8b --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Troubleshooting/ITroubleshootingPlaybackStatusStore.cs @@ -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 CurrentResult { get; } + + void Reset(); + + void RecordCompletion(int exitCode, Option speed); +} diff --git a/ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackResult.cs b/ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackResult.cs new file mode 100644 index 000000000..0b280099c --- /dev/null +++ b/ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackResult.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Troubleshooting; + +public record TroubleshootingPlaybackResult(int ExitCode, Option Speed); diff --git a/ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackStatusStore.cs b/ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackStatusStore.cs new file mode 100644 index 000000000..da7fa5400 --- /dev/null +++ b/ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackStatusStore.cs @@ -0,0 +1,36 @@ +using ErsatzTV.Core.Interfaces.Troubleshooting; + +namespace ErsatzTV.Core.Troubleshooting; + +public class TroubleshootingPlaybackStatusStore : ITroubleshootingPlaybackStatusStore +{ + private readonly object _sync = new(); + private Option _result = Option.None; + + public Option CurrentResult + { + get + { + lock (_sync) + { + return _result; + } + } + } + + public void Reset() + { + lock (_sync) + { + _result = Option.None; + } + } + + public void RecordCompletion(int exitCode, Option speed) + { + lock (_sync) + { + _result = new TroubleshootingPlaybackResult(exitCode, speed); + } + } +} diff --git a/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs b/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs index 8a366e9d4..55bdff023 100644 --- a/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs @@ -45,7 +45,7 @@ public class GraphicsElementControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns(models); - List result = await _controller.GetAll(CancellationToken.None); + List result = await _controller.GetAll(refresh: false, CancellationToken.None); result.ShouldBe(models); } @@ -56,8 +56,34 @@ public class GraphicsElementControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns([]); - List result = await _controller.GetAll(CancellationToken.None); + List 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(), Arg.Any()) + .Returns([]); + + await _controller.GetAll(refresh: false, CancellationToken.None); + + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetAll_Should_Refresh_Before_Listing_When_Refresh_Is_True() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([]); + + await _controller.GetAll(refresh: true, CancellationToken.None); + + Received.InOrder(() => + { + _mediator.Send(Arg.Any(), Arg.Any()); + _mediator.Send(Arg.Any(), Arg.Any()); + }); + } } diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index f3f13d547..6f73776e7 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -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, diff --git a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs index 1c8d8e9f2..305a0e1b1 100644 --- a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs @@ -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(); + _entityLocker = Substitute.For(); + _statusStore = new TroubleshootingPlaybackStatusStore(); _controller = new TroubleshootController( - Channel.CreateUnbounded().Writer, + System.Threading.Channels.Channel.CreateUnbounded().Writer, Substitute.For(), Substitute.For(), Substitute.For(), + _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(q => q.Yaml == "content: []" && q.IsImport), Arg.Any()); } + + [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().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(), Arg.Any()) + .Returns(["a.yml", "b.yml"]); + + List result = await _controller.GetStreamSelectors(CancellationToken.None); + + result.ShouldBe(["a.yml", "b.yml"]); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + } + + [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().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(), Arg.Any()) + .Returns(Either.Left(BaseError.New("nope"))); + + IActionResult result = await _controller.GetSubtitles(999, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(404); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetSubtitles_Should_Map_ViewModels_To_Response() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Either.Right(FakeMediaItemInfo())); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(7, "eng", "English", "subrip") }); + + IActionResult result = await _controller.GetSubtitles(1, CancellationToken.None); + + var subtitles = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + 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().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.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.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(); + } } diff --git a/ErsatzTV/Controllers/Api/GraphicsElementController.cs b/ErsatzTV/Controllers/Api/GraphicsElementController.cs index 7196f1f14..3496d7d96 100644 --- a/ErsatzTV/Controllers/Api/GraphicsElementController.cs +++ b/ErsatzTV/Controllers/Api/GraphicsElementController.cs @@ -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), StatusCodes.Status200OK)] - public async Task> GetAll(CancellationToken cancellationToken) => - await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken); + public async Task> GetAll( + [FromQuery] bool refresh, + CancellationToken cancellationToken) + { + if (refresh) + { + await mediator.Send(new RefreshGraphicsElements(), cancellationToken); + } + + return await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken); + } } diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index cadc54c03..61de653eb 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -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), StatusCodes.Status200OK)] + public async Task> 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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetSubtitles(int mediaItemId, CancellationToken cancellationToken) + { + Either maybeMediaItem = + await mediator.Send(new GetMediaItemInfo(mediaItemId), cancellationToken); + if (maybeMediaItem.IsLeft) + { + return ApiResults.NotFoundProblem(); + } + + List 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 GetPlaybackStatus(CancellationToken cancellationToken) + { + bool running = entityLocker.IsTroubleshootingPlaybackLocked(); + Option 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 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); + } } diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 7b5ed4fe4..8edf58221 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -832,6 +832,7 @@ public class Startup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(Program.InMemoryLogService); diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 30a5a5ec2..669f39879 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -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", diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index dd6c5eb70..ae5be9020 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index 4cea408a1..cb237a70f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 +``, 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). diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 7fe213156..266f18203 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -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 diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index f42ea8a55..426f53fa7 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -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. diff --git a/web/package-lock.json b/web/package-lock.json index d8a0925c1..a183b729a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 28a017149..2edbbf009 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index b589bae03..092942e5f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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: