diff --git a/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs new file mode 100644 index 000000000..9d21ed9aa --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatus.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Libraries; + +namespace ErsatzTV.Application.Libraries; + +public record GetLibraryScanStatus : IRequest>; diff --git a/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs new file mode 100644 index 000000000..707d32cf3 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetLibraryScanStatusHandler.cs @@ -0,0 +1,20 @@ +using ErsatzTV.Core.Api.Libraries; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Metadata; + +namespace ErsatzTV.Application.Libraries; + +public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService) + : IRequestHandler> +{ + public Task> Handle( + GetLibraryScanStatus request, + CancellationToken cancellationToken) + { + List result = scannerProxyService.GetActiveScans() + .Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress)) + .ToList(); + + return Task.FromResult(result); + } +} diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs new file mode 100644 index 000000000..8753aa9ab --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.MediaSources; + +namespace ErsatzTV.Application.MediaSources; + +public record GetAllMediaSourcesForApi : IRequest>; diff --git a/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs new file mode 100644 index 000000000..454fb3230 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs @@ -0,0 +1,133 @@ +#nullable enable +using Dapper; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.MediaSources; + +public class GetAllMediaSourcesForApiHandler( + ILibraryRepository libraryRepository, + IMediaSourceRepository mediaSourceRepository, + IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetAllMediaSourcesForApi request, + CancellationToken cancellationToken) + { + List libraries = (await libraryRepository.GetAll()) + .Filter(ShouldIncludeLibrary) + .ToList(); + + Dictionary itemCountsByLibrary = await GetItemCountsByLibrary(cancellationToken); + Dictionary addressByMediaSourceId = await GetConnectionAddresses(cancellationToken); + + var result = new List(); + foreach (IGrouping group in libraries.GroupBy(l => l.MediaSourceId)) + { + MediaSource mediaSource = group.First().MediaSource; + + List libraryModels = group + .OrderBy(l => l.MediaKind) + .ThenBy(l => l.Name) + .Map(l => new MediaSourceLibraryResponseModel( + l.Id, + l.Name, + l.MediaKind, + l.LastScan, + itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0)) + .ToList(); + + string? address = addressByMediaSourceId.TryGetValue(group.Key, out string? a) ? a : null; + + result.Add( + new MediaSourceResponseModel( + group.Key, + GetKind(mediaSource), + GetName(mediaSource), + address, + libraryModels)); + } + + return result + .OrderBy(s => s.Kind == "Local" ? 0 : 1) + .ThenBy(s => s.Kind) + .ThenBy(s => s.Name) + .ToList(); + } + + private async Task> GetItemCountsByLibrary(CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + IEnumerable counts = await dbContext.Connection.QueryAsync( + @"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count + FROM MediaItem + INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id + GROUP BY LP.LibraryId"); + return counts.ToDictionary(c => c.LibraryId, c => c.Count); + } + + private async Task> GetConnectionAddresses(CancellationToken cancellationToken) + { + var addresses = new Dictionary(); + + foreach (PlexMediaSource plex in await mediaSourceRepository.GetAllPlex()) + { + foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive))) + { + addresses[plex.Id] = connection.Uri; + } + } + + foreach (JellyfinMediaSource jellyfin in await mediaSourceRepository.GetAllJellyfin(cancellationToken)) + { + foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone()) + { + addresses[jellyfin.Id] = connection.Address; + } + } + + foreach (EmbyMediaSource emby in await mediaSourceRepository.GetAllEmby(cancellationToken)) + { + foreach (EmbyConnection connection in emby.Connections.HeadOrNone()) + { + addresses[emby.Id] = connection.Address; + } + } + + return addresses; + } + + private static bool ShouldIncludeLibrary(Library library) => + library switch + { + LocalLibrary => library.Paths.Count > 0, + PlexLibrary plex => plex.ShouldSyncItems, + JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems, + EmbyLibrary emby => emby.ShouldSyncItems, + _ => false + }; + + private static string GetKind(MediaSource mediaSource) => + mediaSource switch + { + PlexMediaSource => "Plex", + JellyfinMediaSource => "Jellyfin", + EmbyMediaSource => "Emby", + _ => "Local" + }; + + private static string GetName(MediaSource mediaSource) => + mediaSource switch + { + PlexMediaSource plex => plex.ServerName, + JellyfinMediaSource jellyfin => jellyfin.ServerName, + EmbyMediaSource emby => emby.ServerName, + _ => "Local" + }; + + private sealed record LibraryItemCount(int LibraryId, int Count); +} diff --git a/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs new file mode 100644 index 000000000..fec106914 --- /dev/null +++ b/ErsatzTV.Core/Api/Libraries/LibraryScanStatusResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.Libraries; + +public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent); diff --git a/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs new file mode 100644 index 000000000..e2b3ac821 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record MediaSourceLibraryResponseModel( + int Id, + string Name, + LibraryMediaKind MediaKind, + DateTime? LastScan, + int ItemCount); diff --git a/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs new file mode 100644 index 000000000..2ee99eb43 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/MediaSourceResponseModel.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace ErsatzTV.Core.Api.MediaSources; + +public record MediaSourceResponseModel( + int Id, + string Kind, + string Name, + string? ConnectionAddress, + List Libraries); diff --git a/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs b/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs index 556f46720..040841d1b 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/IScannerProxyService.cs @@ -1,3 +1,5 @@ +using ErsatzTV.Core.Metadata; + namespace ErsatzTV.Core.Interfaces.Metadata; public interface IScannerProxyService @@ -7,4 +9,5 @@ public interface IScannerProxyService Task Progress(Guid scanId, decimal percentComplete); bool IsActive(Guid scanId); Option GetProgress(int libraryId); + IReadOnlyList GetActiveScans(); } diff --git a/ErsatzTV.Core/Metadata/ScannerProxyService.cs b/ErsatzTV.Core/Metadata/ScannerProxyService.cs index 561116132..8b6b4fed6 100644 --- a/ErsatzTV.Core/Metadata/ScannerProxyService.cs +++ b/ErsatzTV.Core/Metadata/ScannerProxyService.cs @@ -49,4 +49,7 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService public Option GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress) ? progress : Option.None; + + public IReadOnlyList GetActiveScans() => + _activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList(); } diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index efeadd1ae..09d919bc8 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -1,6 +1,8 @@ using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Api.Libraries; using ErsatzTV.Core.Interfaces.Repositories; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; @@ -9,6 +11,13 @@ namespace ErsatzTV.Controllers.Api; [EndpointGroupName("general")] public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) { + [HttpGet("/api/libraries/scan-status")] + [Tags("Libraries")] + [EndpointSummary("Get active library scan status")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetScanStatus(CancellationToken cancellationToken) => + await mediator.Send(new GetLibraryScanStatus(), cancellationToken); + [HttpPost("/api/libraries/{id:int}/scan")] [Tags("Libraries")] [EndpointSummary("Scan library")] diff --git a/ErsatzTV/Controllers/Api/MediaSourcesController.cs b/ErsatzTV/Controllers/Api/MediaSourcesController.cs new file mode 100644 index 000000000..017dc906d --- /dev/null +++ b/ErsatzTV/Controllers/Api/MediaSourcesController.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Core.Api.MediaSources; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class MediaSourcesController(IMediator mediator) +{ + [HttpGet("/api/media-sources")] + [Tags("Media Sources")] + [EndpointSummary("Get all media sources with their libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken); +}