Files
ersatztv/ErsatzTV.Tests/Application/Libraries/CreateLocalLibraryHandlerTests.cs
T
timothyandtimothy 2cf90fb44f
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has started running
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
feat(489): support Jellyfin mixed-content libraries (#493)
Jellyfin libraries typed `mixed` were dropped by JellyfinApiClient.Project's
`_ => None` with no log line, so music and standup content could not be
ingested without a local-library workaround that bypassed Jellyfin entirely.

Adds LibraryMediaKind.Mixed, maps "mixed"/absent/blank CollectionType onto it,
and gives SynchronizeJellyfinLibraryByIdHandler a Mixed arm composing the three
existing per-kind scanners. Jellyfin classifies items server-side via
includeItemTypes, so the passes see disjoint sets; reconciliation is type-scoped
and cannot cross-delete. No new scanner and no DB migration -- MediaItem is TPT
keyed on LibraryPathId, so heterogeneous contents were already legal.

Segregation falls out of the model: a library is a place (one path <-> one
Jellyfin library <-> one ErsatzTV library), so music/standup cannot leak into
Movies or TV Shows.

Also removes the silent-success `_ => Unit.Default` from both scanner
dispatchers, which returned Right for an unhandled kind and stamped LastScan as
though a scan had run, and rejects Mixed for local libraries at the API.

Deliberately Jellyfin-only: local scanners share one video extension list and
would claim each other's files, and LibraryFolder etags are keyed by
LibraryPathId with no notion of kind.

Verified by live E2E against a real Jellyfin, including the interaction with
#494's reconciliation sweep. Four cold review rounds, all MERGEABLE.

fixes #489

Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-20 16:34:51 +00:00

125 lines
4.2 KiB
C#

using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
using ThreadingChannel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Libraries;
[TestFixture]
public class CreateLocalLibraryHandlerTests
{
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_Return_422_When_New_Path_Does_Not_Exist()
{
await SeedLocalMediaSource();
CreateLocalLibraryHandler handler = CreateHandler(new MockFileSystem());
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
result.IfLeft(error => error.Value.ShouldContain("/media/movies"));
}
// LibraryMediaKind.Mixed exists only for remote (Jellyfin) libraries, where the media server
// classifies each item. No local folder scanner handles it, so a local Mixed library would fail
// every scan forever and log at ERROR on every scheduler tick. The API takes a raw
// LibraryMediaKind, so hiding it from the SPA dropdown is not enforcement (#489 review M1).
[Test]
public async Task Handle_Should_Reject_The_Mixed_Media_Kind_For_Local_Libraries()
{
await SeedLocalMediaSource();
var fileSystem = new MockFileSystem();
fileSystem.Directory.CreateDirectory("/media/music");
CreateLocalLibraryHandler handler = CreateHandler(fileSystem);
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
new CreateLocalLibrary("Music", LibraryMediaKind.Mixed, ["/media/music"]),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
result.IfLeft(error => error.Value.ShouldContain("Mixed"));
}
[Test]
public async Task Handle_Should_List_Only_The_Missing_Paths_When_Mixed()
{
await SeedLocalMediaSource();
var fileSystem = new MockFileSystem();
fileSystem.Directory.CreateDirectory("/media/movies");
CreateLocalLibraryHandler handler = CreateHandler(fileSystem);
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies", "/media/missing"]),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
result.IfLeft(error =>
{
error.Value.ShouldContain("/media/missing");
error.Value.ShouldNotContain("/media/movies");
});
}
[Test]
public async Task Handle_Should_Create_When_All_New_Paths_Exist()
{
await SeedLocalMediaSource();
var fileSystem = new MockFileSystem();
fileSystem.Directory.CreateDirectory("/media/movies");
CreateLocalLibraryHandler handler = CreateHandler(fileSystem);
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
result.IfRight(vm =>
{
vm.Name.ShouldBe("Movies");
vm.MediaKind.ShouldBe(LibraryMediaKind.Movies);
});
}
private CreateLocalLibraryHandler CreateHandler(IFileSystem fileSystem) =>
new(
ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>().Writer,
Substitute.For<IEntityLocker>(),
fileSystem,
_db.Factory);
private async Task SeedLocalMediaSource()
{
await using TvContext context = _db.CreateContext();
await context.LocalMediaSources.AddAsync(new LocalMediaSource());
await context.SaveChangesAsync();
}
}