diff --git a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs
index 0f5976a34..e91d14c3e 100644
--- a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs
+++ b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs
@@ -1,4 +1,4 @@
-using System.IO.Abstractions;
+using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
@@ -70,9 +70,23 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
+ .BindT(MediaKindMustBeSupportedLocally)
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
+ ///
+ /// Mixed is only ever produced for remote (Jellyfin) libraries, where the media server classifies
+ /// each item for us. No local folder scanner handles it, so a local Mixed library would fail every
+ /// scan forever. The API takes a raw LibraryMediaKind, so this must be enforced here rather than
+ /// left to the SPA's media-kind options.
+ ///
+ private static Validation MediaKindMustBeSupportedLocally(
+ LocalLibrary localLibrary) =>
+ localLibrary.MediaKind is LibraryMediaKind.Mixed
+ ? BaseError.New(
+ "Local libraries cannot use the Mixed media kind; it is only valid for Jellyfin libraries.")
+ : localLibrary;
+
private static Task> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
diff --git a/ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs b/ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
index d31834026..934b46c88 100644
--- a/ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
+++ b/ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
@@ -1,4 +1,4 @@
-namespace ErsatzTV.Core.Domain;
+namespace ErsatzTV.Core.Domain;
public enum LibraryMediaKind
{
@@ -8,5 +8,13 @@ public enum LibraryMediaKind
OtherVideos = 4,
Songs = 5,
Images = 6,
- RemoteStreams = 7
+ RemoteStreams = 7,
+
+ ///
+ /// A library whose contents are heterogeneous - movies, shows and music videos together.
+ /// Only produced for remote (Jellyfin) libraries whose collection type is "mixed", where the
+ /// media server classifies each item for us. A local library is never Mixed: the local folder
+ /// scanners all share one video extension list and would claim each other's files.
+ ///
+ Mixed = 8
}
diff --git a/ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs b/ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs
index a369015da..2dc74150d 100644
--- a/ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs
+++ b/ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs
@@ -53,6 +53,141 @@ public class JellyfinApiClientTests
libraries[0].ShouldSyncItems.ShouldBeFalse();
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1");
}
+
+ [Test]
+ public async Task Should_Project_Mixed_Libraries()
+ {
+ const string response = """
+ [
+ {
+ "Name": "Music Videos",
+ "CollectionType": "mixed",
+ "ItemId": "library-9",
+ "LibraryOptions": {
+ "PathInfos": []
+ }
+ }
+ ]
+ """;
+
+ var client = new JellyfinApiClient(
+ new MemoryCache(new MemoryCacheOptions()),
+ Substitute.For(),
+ Substitute.For(),
+ new SingleResponseHttpClientFactory(response),
+ Substitute.For>());
+
+ Either> result =
+ await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
+
+ result.IsRight.ShouldBeTrue();
+ List libraries = result.RightToSeq().Single();
+ libraries.Count.ShouldBe(1);
+ libraries[0].Name.ShouldBe("Music Videos");
+ libraries[0].ItemId.ShouldBe("library-9");
+ libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
+ libraries[0].ShouldSyncItems.ShouldBeFalse();
+ libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-9");
+ }
+
+ [Test]
+ public async Task Should_Project_Libraries_With_No_CollectionType_As_Mixed()
+ {
+ const string response = """
+ [
+ {
+ "Name": "Standup",
+ "ItemId": "library-10",
+ "LibraryOptions": {
+ "PathInfos": []
+ }
+ }
+ ]
+ """;
+
+ var client = new JellyfinApiClient(
+ new MemoryCache(new MemoryCacheOptions()),
+ Substitute.For(),
+ Substitute.For(),
+ new SingleResponseHttpClientFactory(response),
+ Substitute.For>());
+
+ Either> result =
+ await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
+
+ result.IsRight.ShouldBeTrue();
+ List libraries = result.RightToSeq().Single();
+ libraries.Count.ShouldBe(1);
+ libraries[0].Name.ShouldBe("Standup");
+ libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
+ }
+
+ // Jellyfin serializes "no content type" as absent, empty or whitespace depending on version;
+ // all three mean mixed content, so all three must project identically.
+ [TestCase("\"CollectionType\": \"\",")]
+ [TestCase("\"CollectionType\": \" \",")]
+ public async Task Should_Project_Libraries_With_Blank_CollectionType_As_Mixed(string collectionTypeLine)
+ {
+ string response = $$"""
+ [
+ {
+ "Name": "Standup",
+ {{collectionTypeLine}}
+ "ItemId": "library-12",
+ "LibraryOptions": {
+ "PathInfos": []
+ }
+ }
+ ]
+ """;
+
+ var client = new JellyfinApiClient(
+ new MemoryCache(new MemoryCacheOptions()),
+ Substitute.For(),
+ Substitute.For(),
+ new SingleResponseHttpClientFactory(response),
+ Substitute.For>());
+
+ Either> result =
+ await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
+
+ result.IsRight.ShouldBeTrue();
+ List libraries = result.RightToSeq().Single();
+ libraries.Count.ShouldBe(1);
+ libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
+ }
+
+ // Guard: mixed must not become a catch-all. Jellyfin "music" (audio) libraries have no
+ // supported scanner, so they must keep falling through to None.
+ [Test]
+ public async Task Should_Not_Project_Unknown_CollectionTypes()
+ {
+ const string response = """
+ [
+ {
+ "Name": "Explo Discovery",
+ "CollectionType": "music",
+ "ItemId": "library-11",
+ "LibraryOptions": {
+ "PathInfos": []
+ }
+ }
+ ]
+ """;
+
+ var client = new JellyfinApiClient(
+ new MemoryCache(new MemoryCacheOptions()),
+ Substitute.For(),
+ Substitute.For(),
+ new SingleResponseHttpClientFactory(response),
+ Substitute.For>());
+
+ Either> result =
+ await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
+
+ result.IsRight.ShouldBeTrue();
+ result.RightToSeq().Single().ShouldBeEmpty();
+ }
}
private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory
diff --git a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
index 5df85c91d..55930f11c 100644
--- a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
+++ b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
@@ -1,4 +1,4 @@
-using System.Globalization;
+using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
@@ -435,7 +435,9 @@ public class JellyfinApiClient : IJellyfinApiClient
}
private Option Project(JellyfinLibraryResponse response) =>
- response.CollectionType?.ToLowerInvariant() switch
+ // normalize "no content type" to null: Jellyfin serializes a mixed library's collection type
+ // as absent, empty or whitespace depending on server version, and all three mean the same thing
+ (string.IsNullOrWhiteSpace(response.CollectionType) ? null : response.CollectionType.ToLowerInvariant()) switch
{
"tvshows" => new JellyfinLibrary
{
@@ -466,6 +468,21 @@ public class JellyfinApiClient : IJellyfinApiClient
},
// TODO: ??? for music libraries
"boxsets" => CacheCollectionLibraryId(response.ItemId),
+
+ // A "mixed content" library. Jellyfin reports these as either the literal "mixed" or with
+ // no collection type at all, depending on server version. Its items are read per type via
+ // includeItemTypes, so the mix is resolved authoritatively by Jellyfin rather than guessed.
+ "mixed" or null => new JellyfinLibrary
+ {
+ ItemId = response.ItemId,
+ Name = response.Name,
+ MediaKind = LibraryMediaKind.Mixed,
+ ShouldSyncItems = false,
+ Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } },
+ PathInfos = GetPathInfos(response)
+ },
+
+ // anything else (notably "music" audio libraries) stays unsupported
_ => None
};
diff --git a/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs b/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs
index f07ad772f..fbce38d62 100644
--- a/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs
+++ b/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs
@@ -165,14 +165,276 @@ public class SynchronizeJellyfinLibraryByIdHandlerTests
// a user-initiated cancellation is not a failure and must not be logged at ERROR (#410)
await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any());
+ // prefix match, matching the mixed-library test below: equality would still be exact for
+ // this single-kind path, but a prefix cannot be quietly defeated by a reworded error
logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
- == "Error synchronizing jellyfin library: Scan was canceled")
+ ?.StartsWith("Error synchronizing jellyfin library:", StringComparison.Ordinal) == true)
.ShouldBeFalse();
logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
== "Scan of jellyfin library Concerts was canceled")
.ShouldBeTrue();
}
+
+ [Test]
+ public async Task Should_Scan_All_Three_Kinds_For_Mixed_Libraries()
+ {
+ var scannerProxy = Substitute.For();
+ var mediaSourceRepository = Substitute.For();
+ var jellyfinSecretStore = Substitute.For();
+ var jellyfinMovieLibraryScanner = Substitute.For();
+ var jellyfinTelevisionLibraryScanner = Substitute.For();
+ var jellyfinMusicVideoLibraryScanner = Substitute.For();
+ var libraryRepository = Substitute.For();
+ var configElementRepository = Substitute.For();
+
+ var library = new JellyfinLibrary
+ {
+ Id = 42,
+ Name = "Music Videos",
+ MediaKind = LibraryMediaKind.Mixed,
+ MediaSourceId = 7
+ };
+ var mediaSource = new JellyfinMediaSource
+ {
+ Id = 7,
+ Connections =
+ [
+ new JellyfinConnection
+ {
+ Address = "http://jellyfin.example",
+ JellyfinMediaSourceId = 7
+ }
+ ]
+ };
+
+ mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
+ mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
+ jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
+ {
+ Address = "http://jellyfin.example",
+ ApiKey = "abc"
+ });
+ configElementRepository.GetValue(
+ Arg.Is(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
+ Arg.Any())
+ .Returns(Task.FromResult