Files
ersatztv/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs
T
timothyandClaude Opus 4.8 c617a01e83 feat(api): add Jellyfin/Emby media-source write API (#202 slice S3)
New JellyfinMediaSourcesController (/api/media-sources/jellyfin, J1-J9) and
EmbyMediaSourcesController (/api/media-sources/emby, E1-E9), wrapping the
existing Jellyfin/Emby MediatR commands per the #202 design doc §A.3/§A.4.

Secure connection contract (§C3/§B, finding 1): the connection GET returns
only { address, hasApiKey } — the API key never crosses the wire. The PUT
retains the existing key when the incoming key is blank, sets a new one when
non-blank, and 422s "API key is required" on a blank first connect.

Finding 7 (lock-release discipline): DisconnectJellyfinHandler and
DisconnectEmbyHandler now wrap their work in try/finally so a throw from any
awaited dependency (repo delete, search-index commit, secret store) still
releases the family lock instead of wedging every future disconnect at 409.

Findings 2c/8 (path-replacement cross-source guard): UpdateJellyfinPathReplacementsHandler
and UpdateEmbyPathReplacementsHandler now reject, before any write, an incoming
positive Id that isn't owned by the route's media source, a null item, or a
blank RemotePath/LocalPath — all 422 with no partial mutation. Defense-in-depth
repo fix: the Jellyfin/Emby path-replacement UPDATE SQL in MediaSourceRepository
now scopes by {Jellyfin,Emby}MediaSourceId (was previously unscoped by Id alone,
allowing a PUT to one source to silently overwrite another source's row). The
Plex path-replacement method (~line 397) is untouched — that's slice S2's file.

Library preferences (§C4a): the controller validates the incoming id set
against the source's known libraries (reject foreign ids, require full
coverage, no Id=0) before dispatch, then — for §C7 — LockLibrary + enqueues
the SynchronizeXLibraries/SynchronizeXLibraryByIdIfNeeded pair per enabled
library (compensating unlock if the enqueue throws), and returns the reloaded
list (ids are not stable across a disable).

404s on id-taking endpoints come from a controller pre-check (GetXMediaSourceById
is None), not a handler NotFoundError, since Either.Apply/ToEitherAsync join any
NotFoundError into a flat 422 (finding 9).

Tests: controller route/404/409/422 tests for both families; disconnect
fault-injection tests proving the lock releases even when a dependency throws;
path-replacement handler tests for cross-source-id/blank/null-item rejection
and correct add/update/delete merge; a repository-level test proving the SQL
fix stops a same-family cross-source path-replacement overwrite.

No new commands, no DB migration, no OpenAPI regen (gated until S1-S3 merge
per the design doc's build-slice plan).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:47:42 +02:00

351 lines
15 KiB
C#

using System.ComponentModel.DataAnnotations;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Emby;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
// Design #202 §A.4 (E1-E9). Copy-symmetric with JellyfinMediaSourcesController.
[ApiController]
public class EmbyMediaSourcesController(
IMediator mediator,
IEntityLocker entityLocker,
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel) : ControllerBase
{
[HttpGet("/api/media-sources/emby", Name = "GetEmbyState")]
[Tags("Emby")]
[EndpointSummary("Get Emby connection state and discovered servers")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)]
public async Task<RemoteMediaSourceStateResponseModel> GetState(CancellationToken cancellationToken)
{
List<EmbyMediaSourceViewModel> sources =
await mediator.Send(new GetAllEmbyMediaSources(), cancellationToken);
EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken);
bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey);
bool isLocked = entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>();
return new RemoteMediaSourceStateResponseModel(
isAuthorized,
isLocked,
sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList());
}
[HttpGet("/api/media-sources/emby/connection", Name = "GetEmbyConnection")]
[Tags("Emby")]
[EndpointSummary("Get the Emby connection address")]
[EndpointDescription(
"Never returns the API key — only whether one is currently configured (design #202 secure connection " +
"contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)]
public async Task<RemoteConnectionResponseModel> GetConnection(CancellationToken cancellationToken)
{
EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken);
return new RemoteConnectionResponseModel(
secrets.Address ?? string.Empty,
!string.IsNullOrWhiteSpace(secrets.ApiKey));
}
[HttpPut("/api/media-sources/emby/connection", Name = "SaveEmbyConnection")]
[Tags("Emby")]
[EndpointSummary("Connect, reconnect, or edit the Emby connection")]
[EndpointDescription(
"A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required " +
"on first connect (no existing secret).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> SaveConnection(
[Required] [FromBody] SaveRemoteConnectionRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>())
{
return ApiResults.ConflictProblem(
"Emby operation in progress",
"An Emby sign-in or sync is already in progress.");
}
if (!Uri.TryCreate(request.Address, UriKind.Absolute, out _))
{
return new UnprocessableEntityObjectResult(
CreateProblemDetails("Address must be an absolute URI."));
}
EmbySecrets existingSecrets = await mediator.Send(new GetEmbySecrets(), cancellationToken);
if (string.IsNullOrWhiteSpace(request.ApiKey) && string.IsNullOrWhiteSpace(existingSecrets.ApiKey))
{
return new UnprocessableEntityObjectResult(CreateProblemDetails("API key is required."));
}
Either<BaseError, Unit> result = await mediator.Send(
request.ToEmbyCommand(existingSecrets.ApiKey),
cancellationToken);
return await result.Match<Task<IActionResult>>(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
EmbySecrets saved = await mediator.Send(new GetEmbySecrets(), cancellationToken);
return new OkObjectResult(
new RemoteConnectionResponseModel(
saved.Address ?? string.Empty,
!string.IsNullOrWhiteSpace(saved.ApiKey)));
});
}
[HttpPost("/api/media-sources/emby/disconnect", Name = "DisconnectEmby")]
[Tags("Emby")]
[EndpointSummary("Disconnect Emby")]
[EndpointDescription("Purges the Emby connection, discovered servers, and all synced Emby content.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
{
if (!entityLocker.LockRemoteMediaSource<EmbyMediaSource>())
{
return ApiResults.ConflictProblem(
"Emby operation in progress",
"An Emby sign-in or sync is already in progress.");
}
Either<BaseError, Unit> result = await mediator.Send(new DisconnectEmby(), cancellationToken);
return result.Match(
Left: error => error.ToErrorResult(),
Right: _ => (IActionResult)new NoContentResult());
}
[HttpGet("/api/media-sources/emby/{id:int}/libraries", Name = "GetEmbyLibraries")]
[Tags("Emby")]
[EndpointSummary("Get an Emby source's libraries")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetLibraries(int id, CancellationToken cancellationToken)
{
Option<EmbyMediaSourceViewModel> maybeSource =
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
if (maybeSource.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<EmbyLibraryViewModel> libraries =
await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken);
return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList());
}
[HttpPut("/api/media-sources/emby/{id:int}/libraries", Name = "ReplaceEmbyLibraryPreferences")]
[Tags("Emby")]
[EndpointSummary("Replace an Emby source's library sync preferences")]
[EndpointDescription(
"The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from " +
"the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this " +
"response rather than the request body.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceLibraryPreferences(
int id,
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
CancellationToken cancellationToken)
{
Option<EmbyMediaSourceViewModel> maybeSource =
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
if (maybeSource.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<EmbyLibraryViewModel> existingLibraries =
await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken);
UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries);
if (validationError is not null)
{
return validationError;
}
Either<BaseError, Unit> result =
await mediator.Send(request.ToEmbyCommand(), cancellationToken);
return await result.Match<Task<IActionResult>>(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
foreach (RemoteLibraryPreferenceRequest library in request.Libraries.Where(l => l.ShouldSyncItems))
{
await EnqueueLibrarySync(id, library.Id, cancellationToken);
}
List<EmbyLibraryViewModel> reloaded =
await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken);
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
});
}
[HttpGet("/api/media-sources/emby/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")]
[Tags("Emby")]
[EndpointSummary("Get an Emby source's path replacements")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPathReplacements(int id, CancellationToken cancellationToken)
{
Option<EmbyMediaSourceViewModel> maybeSource =
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
if (maybeSource.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<EmbyPathReplacementViewModel> replacements =
await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken);
return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList());
}
[HttpPut("/api/media-sources/emby/{id:int}/path-replacements", Name = "ReplaceEmbyPathReplacements")]
[Tags("Emby")]
[EndpointSummary("Replace an Emby source's path replacements")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplacePathReplacements(
int id,
[Required] [FromBody] ReplacePathReplacementsRequest request,
CancellationToken cancellationToken)
{
Option<EmbyMediaSourceViewModel> maybeSource =
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
if (maybeSource.IsNone)
{
return ApiResults.NotFoundProblem();
}
Either<BaseError, Unit> result =
await mediator.Send(request.ToEmbyCommand(id), cancellationToken);
return await result.Match<Task<IActionResult>>(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
List<EmbyPathReplacementViewModel> reloaded =
await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken);
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
});
}
[HttpPost("/api/media-sources/emby/{id:int}/refresh-libraries", Name = "RefreshEmbyLibraries")]
[Tags("Emby")]
[EndpointSummary("Refresh an Emby source's libraries")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> RefreshLibraries(int id, CancellationToken cancellationToken)
{
Option<EmbyMediaSourceViewModel> maybeSource =
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
if (maybeSource.IsNone)
{
return ApiResults.NotFoundProblem();
}
if (entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>())
{
return ApiResults.ConflictProblem(
"Emby operation in progress",
"An Emby sign-in or sync is already in progress.");
}
await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(id), cancellationToken);
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)
{
if (!entityLocker.LockLibrary(libraryId))
{
return;
}
try
{
await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(sourceId), cancellationToken);
await scannerWorkerChannel.WriteAsync(
new SynchronizeEmbyLibraryByIdIfNeeded(libraryId),
cancellationToken);
}
catch
{
entityLocker.UnlockLibrary(libraryId);
throw;
}
}
private static UnprocessableEntityObjectResult ValidateLibraryPreferences(
ReplaceRemoteLibraryPreferencesRequest request,
List<EmbyLibraryViewModel> existingLibraries)
{
List<RemoteLibraryPreferenceRequest> libraries = request.Libraries ?? [];
if (libraries.Any(l => l.Id < 1))
{
return new UnprocessableEntityObjectResult(CreateProblemDetails("Every library id is required."));
}
var existingIds = existingLibraries.Map(l => l.Id).ToList();
var foreignIds = libraries.Filter(l => !existingIds.Contains(l.Id)).Map(l => l.Id).ToList();
if (foreignIds.Count > 0)
{
return new UnprocessableEntityObjectResult(
CreateProblemDetails(
$"Library id(s) {string.Join(", ", foreignIds)} do not belong to this Emby source."));
}
var incomingIds = libraries.Map(l => l.Id).ToList();
var missingIds = existingIds.Filter(existingId => !incomingIds.Contains(existingId)).ToList();
if (missingIds.Count > 0)
{
return new UnprocessableEntityObjectResult(
CreateProblemDetails(
"The request must include every library for this source " +
$"(missing id(s) {string.Join(", ", missingIds)})."));
}
return null;
}
private static ProblemDetails CreateProblemDetails(string detail) =>
new()
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Validation failed",
Detail = detail
};
private static RemoteLibraryResponseModel ProjectToResponseModel(EmbyLibraryViewModel vm) =>
new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems);
private static PathReplacementResponseModel ProjectToResponseModel(EmbyPathReplacementViewModel vm) =>
new(vm.Id, vm.EmbyPath, vm.LocalPath);
}