using ErsatzTV.Application.MediaSources; using ErsatzTV.Core.Api.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_Succeed_On_Empty_Database() { // regression: the Dapper item-count query 500'd on a fresh database because // SQLite reports COUNT(*) as BLOB when there are no rows to infer from var handler = new GetAllMediaSourcesForApiHandler(_db.Factory); var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None); result.ShouldBeEmpty(); } [Test] public async Task Handle_Should_Report_Sentinel_And_Null_LastScan_As_Null() { await using (TvContext context = _db.CreateContext()) { var source = new LocalMediaSource { Libraries = [ new LocalLibrary { Name = "Never Scanned Sentinel", MediaKind = LibraryMediaKind.Movies, LastScan = new DateTime(1, 1, 1), Paths = [MakePath("/media/sentinel", 0)] }, new LocalLibrary { Name = "Never Scanned Null", MediaKind = LibraryMediaKind.Movies, LastScan = null, Paths = [MakePath("/media/nullscan", 0)] }, new LocalLibrary { Name = "Really Scanned", MediaKind = LibraryMediaKind.Movies, LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), Paths = [MakePath("/media/scanned", 0)] } ] }; context.MediaSources.Add(source); await context.SaveChangesAsync(); } var handler = new GetAllMediaSourcesForApiHandler(_db.Factory); var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None); MediaSourceLibraryResponseModel[] libraries = result.Single().Libraries.ToArray(); libraries.Single(l => l.Name == "Never Scanned Sentinel").LastScan.ShouldBeNull(); libraries.Single(l => l.Name == "Never Scanned Null").LastScan.ShouldBeNull(); libraries.Single(l => l.Name == "Really Scanned").LastScan .ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)); } [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() }; }