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..13e007856 --- /dev/null +++ b/ErsatzTV.Application/MediaSources/Queries/GetAllMediaSourcesForApiHandler.cs @@ -0,0 +1,148 @@ +#nullable enable +using Dapper; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.MediaSources; + +public class GetAllMediaSourcesForApiHandler( + IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetAllMediaSourcesForApi request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + List mediaSources = await dbContext.MediaSources + .AsNoTracking() + .Include(s => s.Libraries) + .ThenInclude(l => l.Paths) + .ToListAsync(cancellationToken); + + Dictionary itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken); + Dictionary addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken); + + var result = new List(); + foreach (MediaSource mediaSource in mediaSources) + { + List libraryModels = mediaSource.Libraries + .Filter(ShouldIncludeLibrary) + .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(mediaSource.Id, out string? a) ? a : null; + + result.Add( + new MediaSourceResponseModel( + mediaSource.Id, + 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 static async Task> GetItemCountsByLibrary( + TvContext dbContext, + CancellationToken cancellationToken) + { + IEnumerable counts = await dbContext.Connection.QueryAsync( + new CommandDefinition( + @"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count + FROM MediaItem + INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id + GROUP BY LP.LibraryId", + cancellationToken: cancellationToken)); + + return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count); + } + + private static async Task> GetConnectionAddresses( + TvContext dbContext, + CancellationToken cancellationToken) + { + var addresses = new Dictionary(); + + foreach (PlexMediaSource plex in await dbContext.PlexMediaSources + .AsNoTracking() + .Include(s => s.Connections) + .ToListAsync(cancellationToken)) + { + foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive))) + { + addresses[plex.Id] = connection.Uri; + } + } + + foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources + .AsNoTracking() + .Include(s => s.Connections) + .ToListAsync(cancellationToken)) + { + foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone()) + { + addresses[jellyfin.Id] = connection.Address; + } + } + + foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources + .AsNoTracking() + .Include(s => s.Connections) + .ToListAsync(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(long LibraryId, long 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..967853efd --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/MediaSourceLibraryResponseModel.cs @@ -0,0 +1,11 @@ +#nullable enable +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.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs b/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs new file mode 100644 index 000000000..1e062f32d --- /dev/null +++ b/ErsatzTV.Tests/Application/Libraries/GetLibraryScanStatusHandlerTests.cs @@ -0,0 +1,32 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Api.Libraries; +using ErsatzTV.Core.Metadata; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Libraries; + +[TestFixture] +public class GetLibraryScanStatusHandlerTests +{ + [Test] + public async Task Handle_Should_Return_Active_Scans() + { + var mediator = Substitute.For(); + var scannerProxyService = new ScannerProxyService(mediator); + Guid scanId = scannerProxyService.StartScan(42) + .Match( + Some: id => id, + None: () => throw new AssertionException("Expected scan to start")); + await scannerProxyService.Progress(scanId, 62.5m); + + var handler = new GetLibraryScanStatusHandler(scannerProxyService); + + List result = + await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None); + + result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]); + } +} diff --git a/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs b/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs new file mode 100644 index 000000000..85f421a53 --- /dev/null +++ b/ErsatzTV.Tests/Application/MediaSources/GetAllMediaSourcesForApiHandlerTests.cs @@ -0,0 +1,179 @@ +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.MediaSources; + +[TestFixture] +public class GetAllMediaSourcesForApiHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Handle_Should_Group_Configured_Libraries_By_Source_With_Item_Counts() + { + await SeedMediaSources(); + var handler = new GetAllMediaSourcesForApiHandler(_db.Factory); + + var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None); + + result.Count.ShouldBe(4); + + result[0].Kind.ShouldBe("Local"); + result[0].Name.ShouldBe("Local"); + result[0].ConnectionAddress.ShouldBeNull(); + result[0].Libraries.Single().Name.ShouldBe("Local Movies"); + result[0].Libraries.Single().LastScan.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)); + result[0].Libraries.Single().ItemCount.ShouldBe(2); + + result[1].Kind.ShouldBe("Emby"); + result[1].Name.ShouldBe("Emby Server"); + result[1].ConnectionAddress.ShouldBe("http://emby.local"); + result[1].Libraries.Single().Name.ShouldBe("Emby Shows"); + result[1].Libraries.Single().ItemCount.ShouldBe(1); + + result[2].Kind.ShouldBe("Jellyfin"); + result[2].Name.ShouldBe("Jellyfin Server"); + result[2].ConnectionAddress.ShouldBe("http://jellyfin.local"); + result[2].Libraries.ShouldBeEmpty(); + + result[3].Kind.ShouldBe("Plex"); + result[3].Name.ShouldBe("Plex Server"); + result[3].ConnectionAddress.ShouldBe("http://plex.local"); + result[3].Libraries.Single().Name.ShouldBe("Plex Movies"); + result[3].Libraries.Single().ItemCount.ShouldBe(0); + } + + [Test] + public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries() + { + await SeedMediaSources(); + var handler = new GetAllMediaSourcesForApiHandler(_db.Factory); + + var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None); + + result.SelectMany(s => s.Libraries).Select(l => l.Name) + .ShouldNotContain("Empty Local"); + result.SelectMany(s => s.Libraries).Select(l => l.Name) + .ShouldNotContain("Disabled Jellyfin"); + } + + private async Task SeedMediaSources() + { + await using TvContext context = _db.CreateContext(); + + var localSource = new LocalMediaSource + { + Libraries = + [ + new LocalLibrary + { + Name = "Local Movies", + MediaKind = LibraryMediaKind.Movies, + LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + Paths = [MakePath("/media/movies", 2)] + }, + new LocalLibrary + { + Name = "Empty Local", + MediaKind = LibraryMediaKind.Shows, + Paths = [] + } + ] + }; + + var plexSource = new PlexMediaSource + { + ServerName = "Plex Server", + ProductVersion = "1", + Platform = "Linux", + PlatformVersion = "1", + ClientIdentifier = "plex", + Connections = [new PlexConnection { IsActive = true, Uri = "http://plex.local" }], + PathReplacements = [], + Libraries = + [ + new PlexLibrary + { + Name = "Plex Movies", + MediaKind = LibraryMediaKind.Movies, + Key = "1", + ShouldSyncItems = true, + Paths = [] + } + ] + }; + + var embySource = new EmbyMediaSource + { + ServerName = "Emby Server", + OperatingSystem = "Linux", + Connections = [new EmbyConnection { Address = "http://emby.local" }], + PathReplacements = [], + Libraries = + [ + new EmbyLibrary + { + Name = "Emby Shows", + MediaKind = LibraryMediaKind.Shows, + ItemId = "emby-shows", + ShouldSyncItems = true, + PathInfos = [], + Paths = [MakePath("/emby/shows", 1)] + } + ] + }; + + var jellyfinSource = new JellyfinMediaSource + { + ServerName = "Jellyfin Server", + OperatingSystem = "Linux", + Connections = [new JellyfinConnection { Address = "http://jellyfin.local" }], + PathReplacements = [], + Libraries = + [ + new JellyfinLibrary + { + Name = "Disabled Jellyfin", + MediaKind = LibraryMediaKind.Movies, + ItemId = "jellyfin-movies", + ShouldSyncItems = false, + PathInfos = [], + Paths = [MakePath("/jellyfin/movies", 1)] + } + ] + }; + + context.MediaSources.AddRange(localSource, plexSource, embySource, jellyfinSource); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + } + + private static LibraryPath MakePath(string path, int mediaItemCount) => + new() + { + Path = path, + LibraryFolders = [], + MediaItems = Enumerable.Range(0, mediaItemCount) + .Select(_ => new Movie + { + MovieMetadata = [], + MediaVersions = [], + Collections = [], + CollectionItems = [], + TraktListItems = [] + }) + .Cast() + .ToList() + }; +} diff --git a/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs new file mode 100644 index 000000000..95d67c9a0 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using ErsatzTV.Application.Libraries; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Libraries; +using ErsatzTV.Core.Interfaces.Repositories; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class LibrariesControllerTests +{ + private LibrariesController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new LibrariesController(Substitute.For(), _mediator); + } + + [Test] + public void ScanStatus_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(LibrariesController).GetMethod(nameof(LibrariesController.GetScanStatus)) + ?? throw new AssertionException("Missing action GetScanStatus"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/libraries/scan-status"); + attribute.Name.ShouldBe("GetLibraryScanStatus"); + } + + [Test] + public async Task GetScanStatus_Should_Return_Results_From_Mediator() + { + var expected = new List + { + new(1, 42.5m), + new(2, 99m) + }; + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(expected); + + List result = await _controller.GetScanStatus(CancellationToken.None); + + result.ShouldBe(expected); + } +} diff --git a/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs new file mode 100644 index 000000000..3e2c98b08 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class MediaSourcesControllerTests +{ + private MediaSourcesController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new MediaSourcesController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(MediaSourcesController).GetMethod(nameof(MediaSourcesController.GetAll)) + ?? throw new AssertionException("Missing action GetAll"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/media-sources"); + attribute.Name.ShouldBe("GetMediaSources"); + } + + [Test] + public async Task GetAll_Should_Return_Results_From_Mediator() + { + var expected = new List + { + new( + 1, + "Local", + "Local", + null, + [new MediaSourceLibraryResponseModel(10, "Movies", LibraryMediaKind.Movies, null, 3)]) + }; + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(expected); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(expected); + } +} diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index efeadd1ae..15878394b 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -1,14 +1,23 @@ 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; [ApiController] [EndpointGroupName("general")] -public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) +public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase { + [HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")] + [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..ca774dc1e --- /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) : ControllerBase +{ + [HttpGet("/api/media-sources", Name = "GetMediaSources")] + [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); +}