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 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 GetState(CancellationToken cancellationToken) { List 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(); 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 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 SaveConnection( [Required] [FromBody] SaveRemoteConnectionRequest request, CancellationToken cancellationToken) { if (entityLocker.IsRemoteMediaSourceLocked()) { 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 result = await mediator.Send( request.ToJellyfinCommand(existingSecrets.ApiKey), cancellationToken); return await result.Match>( 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 Disconnect(CancellationToken cancellationToken) { if (!entityLocker.LockRemoteMediaSource()) { return ApiResults.ConflictProblem( "Jellyfin operation in progress", "A Jellyfin sign-in or sync is already in progress."); } Either 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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetLibraries(int id, CancellationToken cancellationToken) { Option maybeSource = await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } List 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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceLibraryPreferences( int id, [Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request, CancellationToken cancellationToken) { Option maybeSource = await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } List existingLibraries = await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken); UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries); if (validationError is not null) { return validationError; } Either result = await mediator.Send(request.ToJellyfinCommand(), cancellationToken); return await result.Match>( 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 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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetPathReplacements(int id, CancellationToken cancellationToken) { Option maybeSource = await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } List 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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplacePathReplacements( int id, [Required] [FromBody] ReplacePathReplacementsRequest request, CancellationToken cancellationToken) { Option maybeSource = await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } Either result = await mediator.Send(request.ToJellyfinCommand(id), cancellationToken); return await result.Match>( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { List 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 RefreshLibraries(int id, CancellationToken cancellationToken) { Option maybeSource = await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } if (entityLocker.IsRemoteMediaSourceLocked()) { 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(); } [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 ScanCollections( int id, [FromQuery] bool deep = false, CancellationToken cancellationToken = default) { Option 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) { 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 existingLibraries) { List 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); }