Merge remote-tracking branch 'origin/main' into feat/253-pr2
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
This commit is contained in:
@@ -211,7 +211,7 @@ public class ChannelController(
|
||||
"progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
|
||||
"Pass mode to force a specific PlayoutBuildMode.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
@@ -233,7 +233,7 @@ public class ChannelController(
|
||||
|
||||
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
|
||||
return new OkResult();
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
return ApiResults.NotFoundProblem();
|
||||
|
||||
@@ -278,6 +278,53 @@ public class EmbyMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/emby/{id:int}/scan-collections", Name = "ScanEmbyCollections")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Scan an Emby source's collections")]
|
||||
[EndpointDescription(
|
||||
"Queues a synchronization of the source's collections (fire-and-forget). Pass ?deep=true for a deep " +
|
||||
"scan. Returns 409 while an Emby collections scan is already in progress.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ScanCollections(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
|
||||
if (!entityLocker.LockEmbyCollections())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Emby collections scan in progress",
|
||||
"An Emby collections scan is already in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyCollections(id, true, deep),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner releases the lock when it processes the message; if the enqueue throws after we
|
||||
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
|
||||
entityLocker.UnlockEmbyCollections();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
|
||||
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
|
||||
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
|
||||
|
||||
@@ -278,6 +278,53 @@ public class JellyfinMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/jellyfin/{id:int}/scan-collections", Name = "ScanJellyfinCollections")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Scan a Jellyfin source's collections")]
|
||||
[EndpointDescription(
|
||||
"Queues a synchronization of the source's collections (fire-and-forget). Pass ?deep=true for a deep " +
|
||||
"scan. Returns 409 while a Jellyfin collections scan is already in progress.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ScanCollections(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
|
||||
if (!entityLocker.LockJellyfinCollections())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Jellyfin collections scan in progress",
|
||||
"A Jellyfin collections scan is already in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinCollections(id, true, deep),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner releases the lock when it processes the message; if the enqueue throws after we
|
||||
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
|
||||
entityLocker.UnlockJellyfinCollections();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
|
||||
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
|
||||
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -22,13 +23,18 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
[EndpointDescription("Queues a scan of the whole library. Pass ?deep=true for a deep (metadata-refresh) scan.")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ScanLibrary(int id, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> ScanLibrary(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
QueueLibraryScanResult result = await mediator.Send(new QueueLibraryScanByLibraryId(id), cancellationToken);
|
||||
QueueLibraryScanResult result =
|
||||
await mediator.Send(new QueueLibraryScanByLibraryId(id, deep), cancellationToken);
|
||||
return result switch
|
||||
{
|
||||
QueueLibraryScanResult.Queued => new AcceptedResult(),
|
||||
@@ -49,19 +55,48 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
[HttpPost("/api/libraries/{id:int}/scan-show")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan show")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
|
||||
{
|
||||
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
|
||||
foreach (string title in maybeTitle)
|
||||
{
|
||||
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
|
||||
QueueShowScanResult result =
|
||||
await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
|
||||
|
||||
return result
|
||||
? new OkResult()
|
||||
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
|
||||
return result switch
|
||||
{
|
||||
QueueShowScanResult.Queued => new AcceptedResult(),
|
||||
QueueShowScanResult.AlreadyScanning => ApiResults.ConflictProblem(
|
||||
"Library scan in progress",
|
||||
$"A scan for library {id} is already in progress; cannot scan an individual show."),
|
||||
QueueShowScanResult.SyncDisabled => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Library sync is disabled",
|
||||
Detail = $"Item sync is disabled for library {id}."
|
||||
}),
|
||||
QueueShowScanResult.Unsupported => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Single show scanning is not supported",
|
||||
Detail = $"Library {id} does not support scanning an individual show."
|
||||
}),
|
||||
QueueShowScanResult.ScanFailed => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Unable to scan show",
|
||||
Detail = $"The scan for show {request.ShowId} in library {id} could not be completed."
|
||||
}),
|
||||
QueueShowScanResult.NotFound => ApiResults.NotFoundProblem($"Library {id} does not exist."),
|
||||
_ => throw new UnreachableException($"Unmapped QueueShowScanResult: {result}")
|
||||
};
|
||||
}
|
||||
|
||||
return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> result = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
return result.Map(ToResponse).ToGetResult();
|
||||
return result.Map(vm => ToResponse(vm, entityLocker.IsPlayoutLocked(id))).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
|
||||
@@ -128,7 +128,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
Option<PlayoutNameViewModel> playout =
|
||||
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
|
||||
return playout.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)),
|
||||
Some: vm => (IActionResult)new CreatedResult(
|
||||
$"/api/playouts/{vm.PlayoutId}",
|
||||
ToResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
});
|
||||
@@ -194,7 +196,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: playout => (IActionResult)new OkObjectResult(ToResponse(playout)));
|
||||
Right: playout => (IActionResult)new OkObjectResult(
|
||||
ToResponse(playout, entityLocker.IsPlayoutLocked(id))));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlayoutNameViewModel>> UpdateScheduleFile(
|
||||
@@ -260,7 +263,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
|
||||
Option<PlayoutNameViewModel> refreshed = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm)),
|
||||
Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm, entityLocker.IsPlayoutLocked(id))),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
}
|
||||
|
||||
@@ -550,14 +553,19 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ResetAllPlayoutsResponseModel), StatusCodes.Status202Accepted)]
|
||||
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already
|
||||
// skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget
|
||||
// bulk enqueue, so it always accepts. See docs/decisions.md 2026-07-10.
|
||||
// bulk enqueue, so it always accepts — the 202 body reports which playouts were queued and
|
||||
// which were skipped (locked, or an unsupported ExternalJson/None kind). See docs/decisions.md 2026-07-10.
|
||||
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
return Accepted();
|
||||
ResetAllPlayoutsResult result = await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
var body = new ResetAllPlayoutsResponseModel(
|
||||
result.QueuedPlayoutIds,
|
||||
result.SkippedLocked,
|
||||
result.SkippedUnsupported);
|
||||
return new AcceptedResult((string)null, body);
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
|
||||
@@ -728,7 +736,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
private static PlayoutHistoryDetailsResponseModel ToDetailsResponse(PlayoutHistoryDetailsViewModel vm) =>
|
||||
new(vm.PlaybackOrder, vm.CollectionType, vm.Name, vm.MediaItemType, vm.MediaItemTitle);
|
||||
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked) =>
|
||||
PlayoutResponseModel.From(
|
||||
vm.PlayoutId,
|
||||
vm.ScheduleKind,
|
||||
@@ -740,7 +748,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.DecoId,
|
||||
vm.DecoName);
|
||||
vm.DecoName,
|
||||
isLocked);
|
||||
|
||||
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
|
||||
new(
|
||||
|
||||
@@ -277,6 +277,51 @@ public class PlexMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/{id:int}/scan-collections", Name = "ScanPlexCollections")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Scan a Plex server's collections")]
|
||||
[EndpointDescription(
|
||||
"Queues a synchronization of the server's collections (fire-and-forget). Pass ?deep=true for a deep " +
|
||||
"scan. Returns 409 while a Plex collections scan is already in progress.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ScanCollections(
|
||||
int id,
|
||||
[FromQuery] bool deep = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The collections lock IS the running scan (§3b): fail to acquire = a scan is already active → 409.
|
||||
if (!entityLocker.LockPlexCollections())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Plex collections scan in progress",
|
||||
"A Plex collections scan is already in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexCollections(id, true, deep),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner releases the lock when it processes the message; if the enqueue throws after we
|
||||
// acquired the lock, release it here (EnqueueWithTraktLock compensating-unlock, §3b)
|
||||
entityLocker.UnlockPlexCollections();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
private async Task<bool> PlexSourceExists(int id, CancellationToken cancellationToken) =>
|
||||
(await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome;
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user