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 scannerWorkerChannel) : ControllerBase { [HttpGet("/api/v1/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 GetState(CancellationToken cancellationToken) { List 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(); return new RemoteMediaSourceStateResponseModel( isAuthorized, isLocked, sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList()); } [HttpGet("/api/v1/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 GetConnection(CancellationToken cancellationToken) { EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken); return new RemoteConnectionResponseModel( secrets.Address ?? string.Empty, !string.IsNullOrWhiteSpace(secrets.ApiKey)); } [HttpPut("/api/v1/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 SaveConnection( [Required][FromBody] SaveRemoteConnectionRequest request, CancellationToken cancellationToken) { if (entityLocker.IsRemoteMediaSourceLocked()) { 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 result = await mediator.Send( request.ToEmbyCommand(existingSecrets.ApiKey), cancellationToken); return await result.Match>( 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/v1/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 Disconnect(CancellationToken cancellationToken) { if (!entityLocker.LockRemoteMediaSource()) { return ApiResults.ConflictProblem( "Emby operation in progress", "An Emby sign-in or sync is already in progress."); } Either result = await mediator.Send(new DisconnectEmby(), cancellationToken); return result.Match( Left: error => error.ToErrorResult(), Right: _ => (IActionResult)new NoContentResult()); } [HttpGet("/api/v1/media-sources/emby/{id:int}/libraries", Name = "GetEmbyLibraries")] [Tags("Emby")] [EndpointSummary("Get an Emby 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 GetEmbyMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } List libraries = await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList()); } [HttpPut("/api/v1/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), 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 GetEmbyMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } List existingLibraries = await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries); if (validationError is not null) { return validationError; } Either result = await mediator.Send(request.ToEmbyCommand(), 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 GetEmbyLibrariesBySourceId(id), cancellationToken); return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); }); } [HttpGet("/api/v1/media-sources/emby/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")] [Tags("Emby")] [EndpointSummary("Get an Emby 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 GetEmbyMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } List replacements = await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken); return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList()); } [HttpPut("/api/v1/media-sources/emby/{id:int}/path-replacements", Name = "ReplaceEmbyPathReplacements")] [Tags("Emby")] [EndpointSummary("Replace an Emby 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 GetEmbyMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } Either result = await mediator.Send(request.ToEmbyCommand(id), cancellationToken); return await result.Match>( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { List reloaded = await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken); return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); }); } [HttpPost("/api/v1/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 RefreshLibraries(int id, CancellationToken cancellationToken) { Option maybeSource = await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); if (maybeSource.IsNone) { return ApiResults.NotFoundProblem(); } if (entityLocker.IsRemoteMediaSourceLocked()) { 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(); } [HttpPost("/api/v1/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 ScanCollections( int id, [FromQuery] bool deep = false, CancellationToken cancellationToken = default) { Option 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) { 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 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 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); }