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>
351 lines
16 KiB
C#
351 lines
16 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using System.Threading.Channels;
|
|
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.Jellyfin;
|
|
using ErsatzTV.Controllers.Api.Requests;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Api.MediaSources;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Locking;
|
|
using ErsatzTV.Core.Jellyfin;
|
|
using ErsatzTV.Extensions;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace ErsatzTV.Controllers.Api;
|
|
|
|
// Design #202 §A.3 (J1-J9). Jellyfin and Emby are copy-symmetric — see EmbyMediaSourcesController.
|
|
[ApiController]
|
|
public class JellyfinMediaSourcesController(
|
|
IMediator mediator,
|
|
IEntityLocker entityLocker,
|
|
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel) : ControllerBase
|
|
{
|
|
[HttpGet("/api/media-sources/jellyfin", Name = "GetJellyfinState")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Get Jellyfin connection state and discovered servers")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)]
|
|
public async Task<RemoteMediaSourceStateResponseModel> GetState(CancellationToken cancellationToken)
|
|
{
|
|
List<JellyfinMediaSourceViewModel> sources =
|
|
await mediator.Send(new GetAllJellyfinMediaSources(), cancellationToken);
|
|
JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
|
|
|
bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey);
|
|
bool isLocked = entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>();
|
|
|
|
return new RemoteMediaSourceStateResponseModel(
|
|
isAuthorized,
|
|
isLocked,
|
|
sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList());
|
|
}
|
|
|
|
[HttpGet("/api/media-sources/jellyfin/connection", Name = "GetJellyfinConnection")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Get the Jellyfin 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)
|
|
{
|
|
JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
|
return new RemoteConnectionResponseModel(
|
|
secrets.Address ?? string.Empty,
|
|
!string.IsNullOrWhiteSpace(secrets.ApiKey));
|
|
}
|
|
|
|
[HttpPut("/api/media-sources/jellyfin/connection", Name = "SaveJellyfinConnection")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Connect, reconnect, or edit the Jellyfin 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<JellyfinMediaSource>())
|
|
{
|
|
return ApiResults.ConflictProblem(
|
|
"Jellyfin operation in progress",
|
|
"A Jellyfin 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."));
|
|
}
|
|
|
|
JellyfinSecrets existingSecrets = await mediator.Send(new GetJellyfinSecrets(), 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.ToJellyfinCommand(existingSecrets.ApiKey),
|
|
cancellationToken);
|
|
|
|
return await result.Match<Task<IActionResult>>(
|
|
Left: error => Task.FromResult(error.ToErrorResult()),
|
|
Right: async _ =>
|
|
{
|
|
JellyfinSecrets saved = await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
|
return new OkObjectResult(
|
|
new RemoteConnectionResponseModel(
|
|
saved.Address ?? string.Empty,
|
|
!string.IsNullOrWhiteSpace(saved.ApiKey)));
|
|
});
|
|
}
|
|
|
|
[HttpPost("/api/media-sources/jellyfin/disconnect", Name = "DisconnectJellyfin")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Disconnect Jellyfin")]
|
|
[EndpointDescription("Purges the Jellyfin connection, discovered servers, and all synced Jellyfin content.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
|
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
|
{
|
|
if (!entityLocker.LockRemoteMediaSource<JellyfinMediaSource>())
|
|
{
|
|
return ApiResults.ConflictProblem(
|
|
"Jellyfin operation in progress",
|
|
"A Jellyfin sign-in or sync is already in progress.");
|
|
}
|
|
|
|
Either<BaseError, Unit> result = await mediator.Send(new DisconnectJellyfin(), cancellationToken);
|
|
return result.Match(
|
|
Left: error => error.ToErrorResult(),
|
|
Right: _ => (IActionResult)new NoContentResult());
|
|
}
|
|
|
|
[HttpGet("/api/media-sources/jellyfin/{id:int}/libraries", Name = "GetJellyfinLibraries")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Get a Jellyfin 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<JellyfinMediaSourceViewModel> maybeSource =
|
|
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
|
if (maybeSource.IsNone)
|
|
{
|
|
return ApiResults.NotFoundProblem();
|
|
}
|
|
|
|
List<JellyfinLibraryViewModel> libraries =
|
|
await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken);
|
|
return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList());
|
|
}
|
|
|
|
[HttpPut("/api/media-sources/jellyfin/{id:int}/libraries", Name = "ReplaceJellyfinLibraryPreferences")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Replace a Jellyfin 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<JellyfinMediaSourceViewModel> maybeSource =
|
|
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
|
if (maybeSource.IsNone)
|
|
{
|
|
return ApiResults.NotFoundProblem();
|
|
}
|
|
|
|
List<JellyfinLibraryViewModel> existingLibraries =
|
|
await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken);
|
|
|
|
UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries);
|
|
if (validationError is not null)
|
|
{
|
|
return validationError;
|
|
}
|
|
|
|
Either<BaseError, Unit> result =
|
|
await mediator.Send(request.ToJellyfinCommand(), 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<JellyfinLibraryViewModel> reloaded =
|
|
await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken);
|
|
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
|
|
});
|
|
}
|
|
|
|
[HttpGet("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "GetJellyfinPathReplacements")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Get a Jellyfin 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<JellyfinMediaSourceViewModel> maybeSource =
|
|
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
|
if (maybeSource.IsNone)
|
|
{
|
|
return ApiResults.NotFoundProblem();
|
|
}
|
|
|
|
List<JellyfinPathReplacementViewModel> replacements =
|
|
await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken);
|
|
return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList());
|
|
}
|
|
|
|
[HttpPut("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "ReplaceJellyfinPathReplacements")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Replace a Jellyfin 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<JellyfinMediaSourceViewModel> maybeSource =
|
|
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
|
if (maybeSource.IsNone)
|
|
{
|
|
return ApiResults.NotFoundProblem();
|
|
}
|
|
|
|
Either<BaseError, Unit> result =
|
|
await mediator.Send(request.ToJellyfinCommand(id), cancellationToken);
|
|
|
|
return await result.Match<Task<IActionResult>>(
|
|
Left: error => Task.FromResult(error.ToErrorResult()),
|
|
Right: async _ =>
|
|
{
|
|
List<JellyfinPathReplacementViewModel> reloaded =
|
|
await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken);
|
|
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
|
|
});
|
|
}
|
|
|
|
[HttpPost("/api/media-sources/jellyfin/{id:int}/refresh-libraries", Name = "RefreshJellyfinLibraries")]
|
|
[Tags("Jellyfin")]
|
|
[EndpointSummary("Refresh a Jellyfin 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<JellyfinMediaSourceViewModel> maybeSource =
|
|
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
|
if (maybeSource.IsNone)
|
|
{
|
|
return ApiResults.NotFoundProblem();
|
|
}
|
|
|
|
if (entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>())
|
|
{
|
|
return ApiResults.ConflictProblem(
|
|
"Jellyfin operation in progress",
|
|
"A Jellyfin sign-in or sync is already in progress.");
|
|
}
|
|
|
|
await scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(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 SynchronizeJellyfinLibraries(sourceId), cancellationToken);
|
|
await scannerWorkerChannel.WriteAsync(
|
|
new SynchronizeJellyfinLibraryByIdIfNeeded(libraryId),
|
|
cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
entityLocker.UnlockLibrary(libraryId);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static UnprocessableEntityObjectResult ValidateLibraryPreferences(
|
|
ReplaceRemoteLibraryPreferencesRequest request,
|
|
List<JellyfinLibraryViewModel> 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 Jellyfin 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(JellyfinLibraryViewModel vm) =>
|
|
new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems);
|
|
|
|
private static PathReplacementResponseModel ProjectToResponseModel(JellyfinPathReplacementViewModel vm) =>
|
|
new(vm.Id, vm.JellyfinPath, vm.LocalPath);
|
|
}
|