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:
@@ -0,0 +1,15 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace ErsatzTV.Core.Api.Troubleshooting;
|
||||||
|
|
||||||
|
// State machine for the SPA's playback-status poll:
|
||||||
|
// "idle" - no session has run since startup / reset
|
||||||
|
// "running" - a session is in flight (IEntityLocker.IsTroubleshootingPlaybackLocked())
|
||||||
|
// "completed" - the last session finished with exit code 0
|
||||||
|
// "failed" - the last session finished with a non-zero exit code
|
||||||
|
// ExitCode/Speed are populated once a result has been recorded; Logs is the tail (last 500 lines)
|
||||||
|
// of the session's logs.txt when present.
|
||||||
|
public record TroubleshootingPlaybackStatusResponseModel(
|
||||||
|
string State,
|
||||||
|
int? ExitCode,
|
||||||
|
double? Speed,
|
||||||
|
string? Logs);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace ErsatzTV.Core.Api.Troubleshooting;
|
||||||
|
|
||||||
|
// Mirrors ErsatzTV.Application.Troubleshooting.SubtitleViewModel. Id is the value the SPA passes
|
||||||
|
// back as the m3u8 endpoint's subtitleId query parameter; the remaining fields are display-only.
|
||||||
|
// Language/Title/Codec are nullable because embedded/sidecar subtitles frequently lack them.
|
||||||
|
public record TroubleshootingSubtitleResponseModel(
|
||||||
|
int Id,
|
||||||
|
string? Language,
|
||||||
|
string? Title,
|
||||||
|
string? Codec);
|
||||||
@@ -10,10 +10,14 @@ using ErsatzTV.Application.Troubleshooting.Queries;
|
|||||||
using ErsatzTV.Controllers.Api.Requests;
|
using ErsatzTV.Controllers.Api.Requests;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Api.Troubleshooting;
|
using ErsatzTV.Core.Api.Troubleshooting;
|
||||||
|
using ErsatzTV.Application.Channels;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||||
|
using ErsatzTV.Core.Troubleshooting;
|
||||||
|
using ErsatzTV.Extensions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -27,6 +31,8 @@ public class TroubleshootController(
|
|||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IConfigElementRepository configElementRepository,
|
IConfigElementRepository configElementRepository,
|
||||||
ITroubleshootingNotifier notifier,
|
ITroubleshootingNotifier notifier,
|
||||||
|
IEntityLocker entityLocker,
|
||||||
|
ITroubleshootingPlaybackStatusStore statusStore,
|
||||||
IMediator mediator) : ControllerBase
|
IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions GeneralJsonOptions = new()
|
private static readonly JsonSerializerOptions GeneralJsonOptions = new()
|
||||||
@@ -291,4 +297,94 @@ public class TroubleshootController(
|
|||||||
|
|
||||||
return NotFound();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"/api/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"UiSettingsResponseModel": {
|
||||||
"required": [
|
"required": [
|
||||||
"isDarkMode",
|
"isDarkMode",
|
||||||
|
|||||||
@@ -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`.*
|
*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
|
## Artists
|
||||||
|
|
||||||
@@ -361,6 +361,9 @@
|
|||||||
| HEAD | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
|
| 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 |
|
| 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 |
|
| 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 |
|
| POST | `/api/troubleshoot/validate-schedule` | ValidateSequentialSchedule | Validate a sequential schedule YAML document |
|
||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|||||||
Vendored
+12
@@ -1410,6 +1410,18 @@ export interface components {
|
|||||||
"qsvCapabilities": null | string;
|
"qsvCapabilities": null | string;
|
||||||
"vaapiCapabilities": null | string;
|
"vaapiCapabilities": null | string;
|
||||||
"videoToolboxCapabilities": 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": {
|
"UiSettingsResponseModel": {
|
||||||
"isDarkMode": boolean;
|
"isDarkMode": boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user