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

This commit is contained in:
2026-07-11 19:44:39 +02:00
77 changed files with 2748 additions and 183 deletions
@@ -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();
}
}
+19 -10
View File
@@ -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")]