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<string>)
- /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 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:46:35 +02:00
co-authored by Claude Fable 5
parent cf33af4572
commit 5815e4b437
6 changed files with 349 additions and 1 deletions
@@ -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);
}
}
+211
View File
@@ -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",