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;