From cf33af4572a68bfbe6e4f300a90e69547b163354 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 8 Jul 2026 23:46:25 +0200 Subject: [PATCH 01/12] feat(troubleshoot): add troubleshooting playback status store Add a singleton ITroubleshootingPlaybackStatusStore (Core, alongside TroubleshootingNotifier) that records the exit code + speed of the most recent troubleshooting playback session. A new MediatR notification handler writes to it on PlaybackTroubleshootingCompletedNotification, and PrepareTroubleshootingPlaybackHandler resets it when a new session starts (both the channel and media-item lock paths). Co-Authored-By: Claude Fable 5 --- .../PrepareTroubleshootingPlaybackHandler.cs | 4 +++ ...ordTroubleshootingPlaybackStatusHandler.cs | 14 ++++++++ .../ITroubleshootingPlaybackStatusStore.cs | 16 +++++++++ .../TroubleshootingPlaybackResult.cs | 3 ++ .../TroubleshootingPlaybackStatusStore.cs | 36 +++++++++++++++++++ ErsatzTV/Startup.cs | 1 + 6 files changed, 74 insertions(+) create mode 100644 ErsatzTV.Application/Troubleshooting/RecordTroubleshootingPlaybackStatusHandler.cs create mode 100644 ErsatzTV.Core/Interfaces/Troubleshooting/ITroubleshootingPlaybackStatusStore.cs create mode 100644 ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackResult.cs create mode 100644 ErsatzTV.Core/Troubleshooting/TroubleshootingPlaybackStatusStore.cs 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/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/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); From 5815e4b437b5aad526b5bc7868219943cd0b8bee Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 8 Jul 2026 23:46:35 +0200 Subject: [PATCH 02/12] feat(api): add troubleshooting stream-selectors, subtitles, and status endpoints Add three GET endpoints to TroubleshootController for the SPA port of the playback troubleshooting page: - /api/troubleshoot/playback/stream-selectors (List) - /api/troubleshoot/playback/subtitles/{mediaItemId} (404 pre-check via GetMediaItemInfo; maps SubtitleViewModel -> TroubleshootingSubtitleResponseModel) - /api/troubleshoot/playback/status (TroubleshootingPlaybackStatusResponseModel: idle/running/completed/failed + exitCode/speed + logs.txt tail) Regenerate v1.json, endpoint-index.md, and the web API types. Co-Authored-By: Claude Fable 5 --- ...ubleshootingPlaybackStatusResponseModel.cs | 15 ++ .../TroubleshootingSubtitleResponseModel.cs | 11 + .../Controllers/Api/TroubleshootController.cs | 96 ++++++++ ErsatzTV/wwwroot/openapi/v1.json | 211 ++++++++++++++++++ docs/endpoint-index.md | 5 +- web/src/api/generated/v1.d.ts | 12 + 6 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Core/Api/Troubleshooting/TroubleshootingPlaybackStatusResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Troubleshooting/TroubleshootingSubtitleResponseModel.cs 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/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/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 30a5a5ec2..5008c67ab 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -12804,6 +12804,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 +20996,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/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/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 1e330a8e0..07d8940be 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -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; From 8ada77632493aaa0e25c28092bf358a4b1714f6e Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 8 Jul 2026 23:46:41 +0200 Subject: [PATCH 03/12] test(troubleshoot): cover new troubleshooting playback endpoints Add controller tests for stream-selectors, subtitles (404 + mapping), and the status endpoint's idle/running/completed/failed transitions (using the real status store), plus a unit test for the notification handler. Add the subtitles 404 case to the OpenAPI ProblemDetails contract test. Co-Authored-By: Claude Fable 5 --- .../OpenApiErrorResponseContractTests.cs | 1 + .../TroubleshootControllerTests.cs | 187 +++++++++++++++++- 2 files changed, 187 insertions(+), 1 deletion(-) 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(); + } } From 6d9619a3bc21a8fdc6aff8d6992eaff68679e5c3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 00:03:21 +0200 Subject: [PATCH 04/12] feat(web): add hls.js and reusable HlsPlayer component Adds hls.js as a web dependency and a small HlsPlayer wrapper that attaches hls.js when MSE is available (config mirrors the legacy _Host.cshtml previewChannel: liveDurationInfinity + unbounded manifest time-to-first-byte, since the troubleshooting playback.m3u8 endpoint blocks until segments exist) and falls back to native HLS on Safari. Tears down the instance on src change and unmount. Co-Authored-By: Claude Fable 5 --- web/package-lock.json | 7 +++ web/package.json | 7 +-- web/src/media/HlsPlayer.tsx | 90 +++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 web/src/media/HlsPlayer.tsx 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/media/HlsPlayer.tsx b/web/src/media/HlsPlayer.tsx new file mode 100644 index 000000000..f9b8c14e2 --- /dev/null +++ b/web/src/media/HlsPlayer.tsx @@ -0,0 +1,90 @@ +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; + 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, className, style }: HlsPlayerProps) { + const videoRef = useRef(null); + const hlsRef = useRef(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; + }, [src]); + + // Final unmount safety net (covers the hls.js instance in every path). + useEffect( + () => () => { + destroyHls(); + }, + [] + ); + + return