fix(235-A): normalize async-op error contracts on Maintenance + Troubleshoot controllers (#235)

Slice A of the async-op API contract normalization.

MaintenanceController:
- EmptyTrash error path: was 500 text/plain (error.ToString()); now maps the
  BaseError Left through ApiResults.ToErrorResult() -> 404 (NotFoundError) / 422
  ProblemDetails. Success stays 200 OkResult. Added ProducesResponseType 200 + 422.
- CleanArtwork: fire-and-forget enqueue of DeleteOrphanedArtwork was a silent 200;
  now returns 202 Accepted (AcceptedResult) since it queues background work.
  Added ProducesResponseType 202. (Controller does not derive from ControllerBase,
  so results are built directly as before.)

TroubleshootController.TroubleshootPlayback (GET|HEAD /api/troubleshoot/playback.m3u8):
- Two bare body-less NotFound() call sites conflated "not found" with "prepare/
  playback failure". Both now return a ProblemDetails body:
  * prepare-failure (result.IsLeft): mapped through error.ToErrorResult() -> 404 for
    NotFoundError (unknown media item/channel) else 422 for a validation BaseError.
  * terminal fall-through (prepare ok but no playable output): kept 404 with a
    distinguishing ApiResults.NotFoundProblem(...) detail.
- Added ProducesResponseType 404 + 422 (409 already present).

Consumer check: the SPA (PlaybackTroubleshootingScreen) feeds the playback.m3u8 URL
straight to hls.js via HlsPlayer, which never inspects the HTTP status code — playback
state is surfaced via the separate /api/troubleshoot/playback/status poll. So the
404->422 split for the validation subcase is safe; no player code branches on the
status code.

Tests: MaintenanceControllerTests (200/422/202 + enqueue assertion),
TroubleshootControllerTests (prepare 404 NotFoundError, 422 validation). All green;
Api error-metadata/contract/security scans still pass.

Note: OpenAPI artifacts (v1.json / v1.d.ts) intentionally NOT regenerated here — the
orchestrator regenerates once after all #235 slices merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:54:21 +02:00
co-authored by Claude Opus 4.8
parent 8e2f995492
commit 5e5f0af684
4 changed files with 139 additions and 10 deletions
@@ -2,6 +2,7 @@ using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -23,17 +24,14 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
[HttpPost("/api/maintenance/empty_trash")]
[Tags("Maintenance")]
[EndpointSummary("Empty trash")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EmptyTrash()
{
Either<BaseError, Unit> result = await mediator.Send(new EmptyTrash());
foreach (BaseError error in result.LeftToSeq())
{
return new ContentResult
{
StatusCode = StatusCodes.Status500InternalServerError,
Content = error.ToString(),
ContentType = "text/plain"
};
return error.ToErrorResult();
}
return new OkResult();
@@ -42,9 +40,10 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
[HttpPost("/api/maintenance/clean_artwork")]
[Tags("Maintenance")]
[EndpointSummary("Clean artwork cache")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<IActionResult> CleanArtwork(CancellationToken cancellationToken)
{
await workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
return new OkResult();
return new AcceptedResult();
}
}
@@ -114,7 +114,9 @@ public class TroubleshootController(
[Tags("Troubleshooting")]
[EndpointSummary("Start a troubleshooting playback session")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> TroubleshootPlayback(
[FromQuery]
int mediaItem,
@@ -174,9 +176,15 @@ public class TroubleshootController(
Optional(start)),
cancellationToken);
if (result.IsLeft)
// Distinguish "prepare failed" from the later "no playable output" fall-through: map the
// handler's BaseError through the standard helper (404 for NotFoundError — e.g. an unknown
// media item/channel — else 422 for a validation failure) with a ProblemDetails body,
// instead of a bare body-less 404. The SPA feeds this URL straight to hls.js (HlsPlayer)
// and never inspects the status code — failures surface via the /status poll — so the
// 404→422 split for validation errors is safe.
foreach (BaseError error in result.LeftToSeq())
{
return NotFound();
return error.ToErrorResult();
}
// Prepare returned a process, so the handler holds the troubleshooting lock now
@@ -273,7 +281,12 @@ public class TroubleshootController(
}
}
return NotFound();
// Terminal fall-through: Prepare succeeded but no playable output was produced (playback
// failed to start, was cancelled, or the segmenter never wrote segments). Keep the 404 status
// the SPA player already tolerates, but attach a distinguishing ProblemDetails body rather
// than a bare NotFound() so the response is self-describing.
return ApiResults.NotFoundProblem(
"Troubleshooting playback did not produce any output. It may have failed to start or been cancelled.");
}
[HttpHead("api/troubleshoot/playback/archive")]