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
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>
125 lines
4.2 KiB
C#
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();
|
|
}
|
|
}
|