aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs26
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs134
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs94
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs58
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs90
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs57
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs35
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs74
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs173
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs79
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs158
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs157
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs103
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs121
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs44
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs64
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs195
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs191
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs103
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs85
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs114
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs138
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs4
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs68
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs145
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs55
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs77
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs89
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs104
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs126
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs179
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs93
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs58
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs307
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs111
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs23
36 files changed, 3695 insertions, 37 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs
new file mode 100644
index 0000000000..a60622902b
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Data;
+
+public class ItemTypeLookupTests
+{
+ public static TheoryData<BaseItemKind> MappedKinds()
+ => new(new ItemTypeLookup().BaseItemKindNames.Keys);
+
+ [Theory]
+ [MemberData(nameof(MappedKinds))]
+ public void BaseItemKindNames_Kind_NamesAnItemType(BaseItemKind kind)
+ {
+ var name = new ItemTypeLookup().BaseItemKindNames[kind];
+
+ var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType(name)).FirstOrDefault(t => t is not null);
+
+ Assert.NotNull(type);
+ Assert.True(typeof(BaseItem).IsAssignableFrom(type), $"{name} is not an item type.");
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs
new file mode 100644
index 0000000000..153e1dc154
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Tests.Item;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Data;
+
+/// <summary>
+/// Statistics taken on a freshly created database describe every table as a single row, and SQLite then plans
+/// the user data and series queries of a filled library as full scans (#17886).
+/// </summary>
+public sealed class SqliteDatabaseStatisticsTests : SqliteDbTestFixture
+{
+ private readonly SqliteDatabaseProvider _provider;
+
+ public SqliteDatabaseStatisticsTests()
+ {
+ _provider = new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance)
+ {
+ DbContextFactory = CreateDbContextFactory()
+ };
+ }
+
+ [Fact]
+ public async Task RunScheduledOptimisation_EmptyLibrary_RecordsNoStatistics()
+ {
+ SeedFolders(3);
+
+ await _provider.RunScheduledOptimisation(CancellationToken.None);
+
+ Assert.Null(ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RunScheduledOptimisation_LibraryWithItems_RecordsStatistics()
+ {
+ SeedFolders(1);
+ SeedEpisodes(4);
+
+ await _provider.RunScheduledOptimisation(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_NoStatistics_Analyzes()
+ {
+ SeedEpisodes(5);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_LibraryChanged_Reanalyzes()
+ {
+ SeedEpisodes(10);
+ Analyze();
+ SeedEpisodes(5);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_EmptyLibrary_RecordsNoStatistics()
+ {
+ SeedFolders(2);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Null(ReadAnalyzedItemCount());
+ }
+
+ private void SeedFolders(int count)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.Folder",
+ IsFolder = true
+ }));
+ context.SaveChanges();
+ }
+
+ private void SeedEpisodes(int count)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.TV.Episode",
+ IsFolder = false
+ }));
+ context.SaveChanges();
+ }
+
+ private void Analyze()
+ {
+ using var context = CreateDbContext();
+ context.Database.ExecuteSqlRaw("ANALYZE");
+ }
+
+ private long CountItems()
+ {
+ using var context = CreateDbContext();
+ return context.BaseItems.LongCount();
+ }
+
+ private long? ReadAnalyzedItemCount()
+ {
+ using var context = CreateDbContext();
+ var hasStatistics = context.Database
+ .SqlQueryRaw<long>("SELECT count(*) AS \"Value\" FROM sqlite_schema WHERE type = 'table' AND name = 'sqlite_stat1'")
+ .Single();
+ if (hasStatistics == 0)
+ {
+ return null;
+ }
+
+ return context.Database
+ .SqlQueryRaw<long?>("SELECT max(CAST(stat AS INTEGER)) AS \"Value\" FROM sqlite_stat1 WHERE tbl = 'BaseItems'")
+ .Single();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index 679e6d17e3..fd84cfb497 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -1,7 +1,11 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using Emby.Server.Implementations.Dto;
+using Jellyfin.Data;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Drawing;
@@ -138,6 +142,96 @@ public class DtoServiceTests
Assert.Equal(9, dto.ChildCount);
}
+ [Fact]
+ public void GetBaseItemDtos_NoUser_SkipsTheChildCountBatch()
+ {
+ // A child count is attached only to a user's dto, so with no user the batch is work whose
+ // result nothing reads - and it is a grouped count over every item, not a cheap one.
+ var (season, _) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10);
+ var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] };
+
+ var dto = _dtoService.GetBaseItemDtos([season], options, user: null, skipVisibilityCheck: true)[0];
+
+ Assert.Null(dto.ChildCount);
+ _libraryManagerMock.Verify(
+ x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_GroupedMoviesView_CountsEveryLibraryGroupedIntoIt()
+ {
+ // The view has no library of its own, so its count is the sum over the libraries the user
+ // grouped into it - including an untyped one, which the view also shows.
+ var user = new User("user", "auth-provider", "reset-provider");
+ var grouped = BuildLibrary(CollectionType.movies);
+ var untyped = BuildLibrary(null);
+ var shows = BuildLibrary(CollectionType.tvshows);
+ var ungrouped = BuildLibrary(CollectionType.movies);
+ user.SetPreference(PreferenceKind.GroupedFolders, [grouped.Id, untyped.Id, shows.Id]);
+
+ // A real root folder would resolve its children through the library it does not have here.
+ var rootFolder = new Mock<Folder>();
+ rootFolder
+ .Setup(x => x.GetChildren(user, true, It.IsAny<InternalItemsQuery>()))
+ .Returns<User, bool, InternalItemsQuery>((_, _, _) => [grouped, untyped, shows, ungrouped]);
+ _libraryManagerMock.Setup(x => x.GetUserRootFolder()).Returns(rootFolder.Object);
+
+ IReadOnlyList<Guid>? counted = null;
+ _libraryManagerMock
+ .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()))
+ .Callback<IReadOnlyList<Guid>, User?>((ids, _) => counted = ids)
+ .Returns<IReadOnlyList<Guid>, User?>((ids, _) => ids.ToDictionary(id => id, _ => 4));
+
+ var view = new UserView { Id = Guid.NewGuid(), Name = "Movies", ViewType = CollectionType.movies };
+ var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] };
+
+ var dto = _dtoService.GetBaseItemDtos([view], options, user, skipVisibilityCheck: true)[0];
+
+ Assert.Equal(grouped.PhysicalFolderIds.Concat(untyped.PhysicalFolderIds), counted);
+ Assert.Equal(16, dto.ChildCount);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_SubViewOfALibrary_DoesNotCountTheLibrary()
+ {
+ // A sub-view hangs off the library the view was built over, but it holds a query over it,
+ // not its children: counting the library would report every movie as "Continue Watching".
+ var user = new User("user", "auth-provider", "reset-provider");
+ var library = BuildLibrary(CollectionType.movies);
+ _libraryManagerMock.Setup(x => x.GetItemById(library.Id)).Returns(library);
+
+ // The fallback count a sub-view falls through to runs a query of its own.
+ _libraryManagerMock
+ .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns([]);
+
+ var subView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ Name = "Continue Watching",
+ ViewType = CollectionType.movieresume,
+ DisplayParentId = library.Id
+ };
+ var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] };
+
+ _dtoService.GetBaseItemDtos([subView], options, user, skipVisibilityCheck: true);
+
+ _libraryManagerMock.Verify(
+ x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()),
+ Times.Never);
+ }
+
+ private static CollectionFolder BuildLibrary(CollectionType? collectionType)
+ {
+ return new CollectionFolder
+ {
+ Id = Guid.NewGuid(),
+ CollectionType = collectionType,
+ PhysicalFolderIds = [Guid.NewGuid(), Guid.NewGuid()]
+ };
+ }
+
private (Season Season, User User) BuildSeason(int playedCount, int totalCount, int childCount)
{
var user = new User("user", "auth-provider", "reset-provider");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs
new file mode 100644
index 0000000000..54fec0a0d3
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Entities;
+
+public sealed class UserViewBuilderTests
+{
+ private static readonly User _user = new("view-filter-test", "provider", "reset");
+
+ [Fact]
+ public void Filter_IsPlayed_CountsAMovieWatchedOnAnAlternateVersionAsPlayed()
+ {
+ // The primary carries no played row of its own; the version that was watched is another file.
+ var onlyWatchedOnAlternate = new Movie { Id = Guid.NewGuid(), Name = "Watched as a second cut" };
+ var watched = new Movie { Id = Guid.NewGuid(), Name = "Watched outright" };
+ var unwatched = new Movie { Id = Guid.NewGuid(), Name = "Not watched" };
+
+ var items = new BaseItem[] { onlyWatchedOnAlternate, watched, unwatched };
+
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager
+ .Setup(m => m.GetUserData(_user, It.IsAny<BaseItem>()))
+ .Returns((User _, BaseItem item) => new UserItemData { Key = item.Id.ToString("N"), Played = item.Id.Equals(watched.Id) });
+ userDataManager
+ .Setup(m => m.GetResumeUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), _user))
+ .Returns(new Dictionary<Guid, VersionResumeData>
+ {
+ [onlyWatchedOnAlternate.Id] = new(Guid.NewGuid(), new UserItemData { Key = "alternate", Played = true })
+ });
+
+ var libraryManager = new Mock<ILibraryManager>();
+
+ var played = UserViewBuilder.Filter(
+ items,
+ _user,
+ new InternalItemsQuery(_user) { IsPlayed = true },
+ userDataManager.Object,
+ libraryManager.Object).ToList();
+
+ var unplayed = UserViewBuilder.Filter(
+ items,
+ _user,
+ new InternalItemsQuery(_user) { IsPlayed = false },
+ userDataManager.Object,
+ libraryManager.Object).ToList();
+
+ // The alternate's playback settles the movie, exactly as the item's own dto reports it.
+ Assert.Equal([onlyWatchedOnAlternate.Id, watched.Id], played.Select(i => i.Id));
+ Assert.Equal([unwatched.Id], unplayed.Select(i => i.Id));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs
index 22667ee82d..b9ae16255e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs
@@ -1,7 +1,11 @@
using System;
using System.Buffers;
using System.IO;
+using System.Net.WebSockets;
+using System.Text;
using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
using Emby.Server.Implementations.HttpServer;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
@@ -48,6 +52,92 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer
Assert.Throws<JsonException>(() => con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed));
}
+ [Fact]
+ public async Task ReceiveAsync_SocketTornDownWhileAnswering_RaisesClosedWithoutThrowing()
+ {
+ // The keep-alive watchdog can dispose a connection while the receive loop is
+ // answering a message on it. The failing answer must not escape into the request
+ // handler, as that would skip the Closed event the session needs to release it.
+ var socket = new DisposedOnSendWebSocket(Encoding.UTF8.GetBytes("{\"MessageType\":\"KeepAlive\"}"));
+ var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), socket, null!, null!)
+ {
+ OnReceive = _ => Task.CompletedTask
+ };
+
+ var closed = false;
+ con.Closed += (_, _) => closed = true;
+
+ await con.ReceiveAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(closed);
+ Assert.Equal(1, socket.SendAttempts);
+ }
+
+ /// <summary>
+ /// A socket that hands out a single message and then behaves like a socket that was
+ /// disposed underneath the receive loop.
+ /// </summary>
+ internal sealed class DisposedOnSendWebSocket : WebSocket
+ {
+ private readonly byte[] _message;
+ private bool _received;
+
+ public DisposedOnSendWebSocket(byte[] message)
+ {
+ _message = message;
+ }
+
+ public int SendAttempts { get; private set; }
+
+ public override WebSocketCloseStatus? CloseStatus => null;
+
+ public override string? CloseStatusDescription => null;
+
+ public override string? SubProtocol => null;
+
+ public override WebSocketState State => SendAttempts == 0 ? WebSocketState.Open : WebSocketState.Closed;
+
+ public override void Abort()
+ {
+ }
+
+ public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public override void Dispose()
+ {
+ }
+
+ public override ValueTask<ValueWebSocketReceiveResult> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_received, this);
+
+ _received = true;
+ _message.CopyTo(buffer);
+ return ValueTask.FromResult(new ValueWebSocketReceiveResult(_message.Length, WebSocketMessageType.Text, true));
+ }
+
+ public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)
+ => throw new NotImplementedException();
+
+ public override ValueTask SendAsync(ReadOnlyMemory<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
+ => throw FailSend();
+
+ public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
+ => throw FailSend();
+
+ private WebSocketException FailSend()
+ {
+ SendAttempts++;
+ return new WebSocketException(
+ WebSocketError.InvalidState,
+ "The WebSocket is in an invalid state ('Closed') for this operation. Valid states are: 'Open, CloseReceived'");
+ }
+ }
+
internal sealed class BufferSegment : ReadOnlySequenceSegment<byte>
{
public BufferSegment(Memory<byte> memory)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs
new file mode 100644
index 0000000000..fd5f8e4160
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs
@@ -0,0 +1,57 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.IO;
+
+public class FileRefresherTests
+{
+ [Fact]
+ public async Task ProcessPathChanges_PathLookupThrows_StillRefreshesRemainingPaths()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("filerefresher");
+ try
+ {
+ // Ordered so the failing path is dequeued first.
+ var failingPath = Path.Combine(tempDir.FullName, "failing", "episode.mkv");
+ var workingPath = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "working")).FullName;
+
+ var workingItem = new Folder { Path = workingPath, Name = "working" };
+ var workingItemFound = new TaskCompletionSource();
+
+ var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose);
+ libraryManager.Setup(x => x.FindByPath(failingPath, null))
+ .Throws(new ObjectDisposedException("IServiceProvider"));
+ libraryManager.Setup(x => x.FindByPath(workingPath, null))
+ .Returns(workingItem)
+ .Callback(() => workingItemFound.TrySetResult());
+
+ var configurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Loose);
+ configurationManager.Setup(x => x.Configuration)
+ .Returns(new ServerConfiguration { LibraryMonitorDelay = 1 });
+
+ using var refresher = new FileRefresher(
+ failingPath,
+ configurationManager.Object,
+ libraryManager.Object,
+ NullLogger.Instance);
+ refresher.AddPath(workingPath);
+
+ await workingItemFound.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+
+ libraryManager.Verify(x => x.FindByPath(failingPath, null), Times.Once);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
index 6cadfacce8..b39ca83483 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
@@ -100,6 +100,41 @@ public partial class ManagedFileSystemTests
Assert.Equal(expectedFileName, _sut.GetValidFilename(filename));
}
+ [Theory]
+ [InlineData("/media", "/media/tv", true)]
+ [InlineData("/media", "/media/tv/show/episode.mkv", true)]
+ [InlineData("/media/", "/media/tv", true)]
+ [InlineData("/", "/media", true)]
+ [InlineData("/media", "/media", false)]
+ [InlineData("/media", "/media/", true)]
+ [InlineData("/media", "/data/media/tv", false)]
+ [InlineData("/media", "/mediastuff/tv", false)]
+ [InlineData("/data/media", "/data/media/tv", true)]
+ [InlineData("/media/tv", "/media", false)]
+ [InlineData("/MEDIA", "/media/tv", false)]
+ public void ContainsSubPath_Unix_ReturnsExpected(string parentPath, string path, bool expected)
+ {
+ Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix-only test");
+
+ Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path));
+ }
+
+ [Theory]
+ [InlineData(@"C:\media", @"C:\media\tv", true)]
+ [InlineData(@"C:\media\", @"C:\media\tv", true)]
+ [InlineData(@"C:\", @"C:\media", true)]
+ [InlineData(@"C:\media", @"C:\media", false)]
+ [InlineData(@"C:\media", @"C:\data\media\tv", false)]
+ [InlineData(@"C:\media", @"C:\mediastuff\tv", false)]
+ [InlineData(@"C:\MEDIA", @"C:\media\tv", true)]
+ [InlineData(@"C:\media", @"C:\media/tv", true)]
+ public void ContainsSubPath_Windows_ReturnsExpected(string parentPath, string path, bool expected)
+ {
+ Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows-only test");
+
+ Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path));
+ }
+
[Fact]
public void GetFileInfo_DanglingSymlink_ExistsFalse()
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs
new file mode 100644
index 0000000000..b88296a06a
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Images;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Querying;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Images;
+
+/// <summary>
+/// A music library is collaged from its artists' backdrops. Artists are by-name items with no
+/// library of their own, so they have to be asked for through the by-name listing, which reaches
+/// them through the tracks that credit them; an item query for them ignores the library scope and
+/// hands back the artists of every music library.
+/// </summary>
+public sealed class CollectionFolderImageProviderTests
+{
+ [Fact]
+ public void GetItemsWithImages_MusicLibrary_AsksForTheArtistsOfThatLibraryOnly()
+ {
+ var view = new CollectionFolder { Id = Guid.NewGuid(), CollectionType = CollectionType.music };
+ var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "Artist" };
+
+ InternalItemsQuery? query = null;
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(l => l.GetAllArtists(It.IsAny<InternalItemsQuery>()))
+ .Callback<InternalItemsQuery>(q => query = q)
+ .Returns(new QueryResult<(BaseItem Item, ItemCounts ItemCounts)>([(artist, new ItemCounts())]));
+
+ var items = CreateProvider(libraryManager.Object).GetItems(view);
+
+ Assert.Equal([artist], items);
+ Assert.NotNull(query);
+ Assert.Equal([view.Id], query.AncestorIds);
+ Assert.Equal([ImageType.Primary], query.ImageTypes);
+ Assert.Equal(8, query.Limit);
+ }
+
+ private static TestableCollectionFolderImageProvider CreateProvider(ILibraryManager libraryManager)
+ {
+ return new TestableCollectionFolderImageProvider(
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IProviderManager>(),
+ Mock.Of<IApplicationPaths>(),
+ Mock.Of<IImageProcessor>(),
+ libraryManager);
+ }
+
+ private sealed class TestableCollectionFolderImageProvider : CollectionFolderImageProvider
+ {
+ public TestableCollectionFolderImageProvider(
+ IFileSystem fileSystem,
+ IProviderManager providerManager,
+ IApplicationPaths applicationPaths,
+ IImageProcessor imageProcessor,
+ ILibraryManager libraryManager)
+ : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager)
+ {
+ }
+
+ public IReadOnlyList<BaseItem> GetItems(BaseItem item) => GetItemsWithImages(item);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
new file mode 100644
index 0000000000..0c1d453293
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Moq;
+using Xunit;
+using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ArtistNameLookupTests : SqliteDbTestFixture
+{
+ private readonly CommandRecorder _recorder;
+
+ public ArtistNameLookupTests()
+ : this(new CommandRecorder())
+ {
+ }
+
+ private ArtistNameLookupTests(CommandRecorder recorder)
+ : base(recorder)
+ {
+ _recorder = recorder;
+ }
+
+ [Theory]
+ [InlineData("Björk", "bjork")]
+ [InlineData("AC/DC", "ac dc")]
+ [InlineData("An Artist", " AN ARTIST ")]
+ [InlineData("Artist", "ARTIST")]
+ public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName)
+ {
+ var lookup = new ItemTypeLookup();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = artistId,
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.Person]
+ });
+ context.SaveChanges();
+ }
+
+ var manager = CreateLibraryManager(lookup);
+ _recorder.Commands.Clear();
+
+ Assert.Equal(artistId, manager.GetArtist(requestedName).Id);
+ var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal));
+ Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal));
+ Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id);
+ }
+
+ [Fact]
+ public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch()
+ {
+ var lookup = new ItemTypeLookup();
+ var parentId = Guid.NewGuid();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = parentId,
+ Name = "Music",
+ Type = lookup.BaseItemKindNames[BaseItemKind.Folder]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = "Bjork",
+ CleanName = "Bjork".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = artistId,
+ ParentId = parentId,
+ Name = "Björk",
+ CleanName = "Björk".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ });
+ context.SaveChanges();
+ }
+
+ Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id);
+ }
+
+ private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup)
+ {
+ var repository = CreateBaseItemRepository(lookup);
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ fixture.Inject<IItemRepository>(repository);
+ fixture.Inject<ILinkedChildrenService>(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository));
+ return fixture.Create<ServerLibraryManager>();
+ }
+
+ private string[] Explain(RecordedCommand query)
+ {
+ using var context = CreateDbContext();
+ using var command = context.Database.GetDbConnection().CreateCommand();
+#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters.
+ command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql;
+#pragma warning restore CA2100
+ foreach (var value in query.Parameters)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = value.Name;
+ parameter.Value = value.Value;
+ command.Parameters.Add(parameter);
+ }
+
+ using var reader = command.ExecuteReader();
+ var plan = new List<string>();
+ while (reader.Read())
+ {
+ plan.Add(reader.GetString(3));
+ }
+
+ return plan.ToArray();
+ }
+
+ private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters);
+
+ private sealed class CommandRecorder : DbCommandInterceptor
+ {
+ public List<RecordedCommand> Commands { get; } = [];
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ private void Record(DbCommand command) => Commands.Add(new RecordedCommand(
+ command.CommandText,
+ command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs
new file mode 100644
index 0000000000..abe1e59496
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the user data rows <see cref="BaseItemMapper"/> hands the domain item. A domain item is
+/// held for as long as its folder holds it, so a row that still points back at the entity it was
+/// read with would keep that entity - and everything loaded alongside it - alive with it.
+/// </summary>
+public class BaseItemMapperUserDataTests
+{
+ [Fact]
+ public void Map_CopiesUserDataWithoutTheEntityGraphBehindIt()
+ {
+ var itemId = Guid.NewGuid();
+ var userId = Guid.NewGuid();
+ var user = new User("someone", "Default", "Default");
+ var entity = new BaseItemEntity { Id = itemId, Type = "MediaBrowser.Controller.Entities.TV.Episode" };
+
+ var row = new UserData
+ {
+ ItemId = itemId,
+ Item = entity,
+ UserId = userId,
+ User = user,
+ CustomDataKey = "key",
+ PlayCount = 3,
+ PlaybackPositionTicks = 1234,
+ IsFavorite = true,
+ Played = true,
+ Rating = 7.5,
+ LastPlayedDate = new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc),
+ AudioStreamIndex = 1,
+ SubtitleStreamIndex = 2,
+ Likes = true
+ };
+
+ entity.UserData = [row];
+
+ var dto = BaseItemMapper.Map(entity, new Folder(), null);
+
+ var mapped = Assert.Single(dto.UserData);
+ Assert.Null(mapped.Item);
+ Assert.Null(mapped.User);
+
+ // The values callers actually read still come through.
+ Assert.Equal(itemId, mapped.ItemId);
+ Assert.Equal(userId, mapped.UserId);
+ Assert.Equal("key", mapped.CustomDataKey);
+ Assert.Equal(3, mapped.PlayCount);
+ Assert.Equal(1234, mapped.PlaybackPositionTicks);
+ Assert.True(mapped.IsFavorite);
+ Assert.True(mapped.Played);
+ Assert.Equal(7.5, mapped.Rating);
+ Assert.Equal(row.LastPlayedDate, mapped.LastPlayedDate);
+ Assert.Equal(1, mapped.AudioStreamIndex);
+ Assert.Equal(2, mapped.SubtitleStreamIndex);
+ Assert.True(mapped.Likes);
+ }
+
+ [Fact]
+ public void Map_WithoutUserData_YieldsAnEmptyCollection()
+ {
+ var entity = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.Folder"
+ };
+
+ var dto = BaseItemMapper.Map(entity, new Folder(), null);
+
+ Assert.Empty(dto.UserData);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs
new file mode 100644
index 0000000000..3c8bd9fd50
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// A user restricted to a set of allowed tags still has to see the by-name items - genres, studios,
+/// artists - of the media those tags let through. The by-name item carries none of the media's tags,
+/// so the allowed-tags filter would otherwise hide every one of them.
+/// </summary>
+public sealed class BaseItemRepositoryAllowedTagsByNameTests : SqliteDbTestFixture
+{
+ private const string AllowedTag = "kids";
+
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryAllowedTagsByNameTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+ Seed();
+ }
+
+ [Fact]
+ public void GetGenres_WithAllowedTags_ReturnsGenresOfAllowedItems()
+ {
+ var result = _repository.GetGenres(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Comedy"], Names(result));
+ }
+
+ [Fact]
+ public void GetStudios_WithAllowedTags_ReturnsStudiosOfAllowedItems()
+ {
+ var result = _repository.GetStudios(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Pixar"], Names(result));
+ }
+
+ [Fact]
+ public void GetAllArtists_WithAllowedTags_ReturnsArtistsOfAllowedItems()
+ {
+ var result = _repository.GetAllArtists(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Raffi"], Names(result));
+ }
+
+ [Fact]
+ public void GetGenres_WithoutAllowedTags_ReturnsEveryGenre()
+ {
+ var result = _repository.GetGenres(CreateQuery());
+
+ Assert.Equal(["Comedy", "Horror", "Jazz"], Names(result));
+ }
+
+ private static string[] Names(MediaBrowser.Model.Querying.QueryResult<(BaseItem Item, MediaBrowser.Model.Dto.ItemCounts? ItemCounts)> result)
+ => result.Items.Select(i => i.Item.Name!).Order(StringComparer.Ordinal).ToArray();
+
+ private static InternalItemsQuery CreateQuery(params string[] allowedTags)
+ {
+ var user = new User("restricted", "auth", "reset");
+ if (allowedTags.Length > 0)
+ {
+ user.SetPreference(PreferenceKind.AllowedTags, allowedTags);
+ }
+
+ return new InternalItemsQuery(user);
+ }
+
+ private void Seed()
+ {
+ using var context = CreateDbContext();
+
+ // Tagged media, plus the by-name items describing it.
+ var movie = CreateItem(BaseItemKind.Movie, "Allowed Movie", mediaType: "Video");
+ var song = CreateItem(BaseItemKind.Audio, "Allowed Song", mediaType: "Audio");
+ var genre = CreateItem(BaseItemKind.Genre, "Comedy");
+ var studio = CreateItem(BaseItemKind.Studio, "Pixar");
+ var artist = CreateItem(BaseItemKind.MusicArtist, "Raffi");
+
+ // Media the allow list keeps out, plus the by-name items only it is described by.
+ var blockedMovie = CreateItem(BaseItemKind.Movie, "Untagged Movie", mediaType: "Video");
+ var blockedGenre = CreateItem(BaseItemKind.Genre, "Horror");
+
+ // A genre written on nothing but a by-name item: that item is no more visible than the
+ // media behind it, so the genre must not leak into a restricted user's list.
+ var artistOnlyGenre = CreateItem(BaseItemKind.Genre, "Jazz");
+ var blockedArtist = CreateItem(BaseItemKind.MusicArtist, "Mingus");
+
+ var tag = CreateItemValue(ItemValueType.Tags, AllowedTag);
+ var comedy = CreateItemValue(ItemValueType.Genre, "Comedy");
+ var horror = CreateItemValue(ItemValueType.Genre, "Horror");
+ var jazz = CreateItemValue(ItemValueType.Genre, "Jazz");
+ var pixar = CreateItemValue(ItemValueType.Studios, "Pixar");
+ var raffi = CreateItemValue(ItemValueType.Artist, "Raffi");
+
+ context.BaseItems.AddRange(movie, song, genre, studio, artist, blockedMovie, blockedGenre, artistOnlyGenre, blockedArtist);
+ context.ItemValues.AddRange(tag, comedy, horror, jazz, pixar, raffi);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, tag),
+ CreateMap(movie, comedy),
+ CreateMap(movie, pixar),
+ CreateMap(song, tag),
+ CreateMap(song, raffi),
+ CreateMap(blockedMovie, horror),
+ CreateMap(blockedArtist, jazz));
+ context.SaveChanges();
+ }
+
+ private BaseItemEntity CreateItem(BaseItemKind kind, string name, string? mediaType = null)
+ {
+ var id = Guid.NewGuid();
+
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _itemTypeLookup.BaseItemKindNames[kind],
+ Name = name,
+ CleanName = name.ToLowerInvariant(),
+ PresentationUniqueKey = id.ToString("N"),
+ MediaType = mediaType,
+ IsMovie = kind == BaseItemKind.Movie,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private static ItemValue CreateItemValue(ItemValueType type, string value)
+ {
+ return new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = value,
+ CleanValue = value.ToLowerInvariant()
+ };
+ }
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ {
+ return new ItemValueMap
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs
new file mode 100644
index 0000000000..1f8d9820d6
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Artists are by-name items: they live outside any library and carry no TopParentId, so a plain
+/// item query for them is exempt from the library filter and spans every music library. Only the
+/// by-name listings, which reach the artist through the tracks that credit it, can be scoped to
+/// one library.
+/// </summary>
+public sealed class BaseItemRepositoryArtistLibraryScopeTests : SqliteDbTestFixture
+{
+ private static readonly Guid _firstLibrary = Guid.Parse("11111111-0000-0000-0000-000000000001");
+ private static readonly Guid _secondLibrary = Guid.Parse("22222222-0000-0000-0000-000000000001");
+
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryArtistLibraryScopeTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+
+ Seed("First Artist", "first artist", _firstLibrary, hasImage: true);
+ Seed("Second Artist", "second artist", _secondLibrary, hasImage: true);
+ }
+
+ [Fact]
+ public void GetItemList_MusicArtistsScopedToOneLibrary_ReturnsEveryLibrarysArtists()
+ {
+ // The shape the library cover image used to be built from. By-name types are exempt from
+ // the TopParentId filter, so the scope is silently dropped.
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ IncludeItemTypes = [BaseItemKind.MusicArtist],
+ TopParentIds = [_firstLibrary]
+ });
+
+ Assert.Equal(["First Artist", "Second Artist"], result.Select(i => i.Name).OrderBy(n => n));
+ }
+
+ [Fact]
+ public void GetAllArtists_ScopedToOneLibrary_ReturnsOnlyThatLibrarysArtists()
+ {
+ var result = _repository.GetAllArtists(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ TopParentIds = [_firstLibrary]
+ });
+
+ var (artist, _) = Assert.Single(result.Items);
+ Assert.Equal("First Artist", artist.Name);
+ }
+
+ [Fact]
+ public void GetAllArtists_ImageTypes_DropsArtistsWithoutThatImage()
+ {
+ // The collage has nothing to draw with an artist that has no image, so the listing has to
+ // honour the image filter the caller asked for.
+ Seed("Third Artist", "third artist", _firstLibrary, hasImage: false);
+
+ var result = _repository.GetAllArtists(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ ImageTypes = [ImageType.Primary],
+ TopParentIds = [_firstLibrary]
+ });
+
+ var (artist, _) = Assert.Single(result.Items);
+ Assert.Equal("First Artist", artist.Name);
+ }
+
+ /// <summary>
+ /// Seeds one by-name artist row and a track in the given library crediting it.
+ /// </summary>
+ /// <param name="name">The artist name.</param>
+ /// <param name="cleanName">The cleaned artist name, which is what links the two rows.</param>
+ /// <param name="topParentId">The library the track belongs to.</param>
+ /// <param name="hasImage">Whether the artist row carries a primary image.</param>
+ private void Seed(string name, string cleanName, Guid topParentId, bool hasImage)
+ {
+ using var ctx = CreateDbContext();
+
+ var artistId = Guid.NewGuid();
+ var artist = new BaseItemEntity
+ {
+ Id = artistId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist],
+ Name = name,
+ CleanName = cleanName,
+ PresentationUniqueKey = artistId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ };
+
+ if (hasImage)
+ {
+ artist.Images =
+ [
+ new BaseItemImageInfo
+ {
+ Id = Guid.NewGuid(),
+ ItemId = artistId,
+ Item = artist,
+ ImageType = ImageInfoImageType.Primary,
+ Path = $"/metadata/artists/{cleanName}/folder.jpg"
+ }
+ ];
+ }
+
+ ctx.BaseItems.Add(artist);
+
+ var trackId = Guid.NewGuid();
+ var track = new BaseItemEntity
+ {
+ Id = trackId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio],
+ Name = $"{name} - Track",
+ CleanName = $"{cleanName} - track",
+ PresentationUniqueKey = trackId.ToString("N"),
+ MediaType = "Audio",
+ TopParentId = topParentId,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ ctx.BaseItems.Add(track);
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.AlbumArtist,
+ Value = name,
+ CleanValue = cleanName
+ };
+
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = trackId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = track,
+ ItemValue = itemValue
+ });
+
+ ctx.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs
new file mode 100644
index 0000000000..5e045e9f83
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the children query the library scan runs against a folder: a version merged by hand is
+/// hidden from ordinary queries, but the scan has to see it or it takes the row for a new item and
+/// recreates it, splitting the version group apart again.
+/// </summary>
+public sealed class BaseItemRepositoryChildrenTests : SqliteDbTestFixture
+{
+ private static readonly Guid _folderId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _primaryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ private static readonly Guid _mergedVersionId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ private static readonly Guid _ownedVersionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ private readonly BaseItemRepository _repository;
+
+ public BaseItemRepositoryChildrenTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+
+ var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ using var ctx = CreateDbContext();
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _folderId,
+ Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!,
+ Name = "Movies",
+ Path = "/movies",
+ IsFolder = true
+ });
+ ctx.BaseItems.Add(CreateMovie(_primaryId, movieTypeName!, "Big Buck Bunny", "/media1/Big Buck Bunny/bbb-1080p.mp4", null, null));
+ ctx.BaseItems.Add(CreateMovie(_mergedVersionId, movieTypeName!, "Big Buck Bunny", "/media2/Big Buck Bunny/bbb-2160p.mp4", _primaryId, null));
+ ctx.BaseItems.Add(CreateMovie(_ownedVersionId, movieTypeName!, "Big Buck Bunny - 720p", "/media1/Big Buck Bunny/bbb-720p.mp4", _primaryId, _primaryId));
+ ctx.SaveChanges();
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolder_ExcludesAlternateVersionsByDefault()
+ {
+ var result = _repository.GetItemList(new InternalItemsQuery { ParentId = _folderId });
+
+ var item = Assert.Single(result);
+ Assert.Equal(_primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_KeepsMergedVersion()
+ {
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ ParentId = _folderId,
+ IncludeAlternateVersions = true
+ });
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, i => i.Id.Equals(_primaryId));
+ Assert.Contains(result, i => i.Id.Equals(_mergedVersionId));
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_StillExcludesOwnedVersion()
+ {
+ // A version stored next to the file it belongs to is owned by its primary and is never
+ // resolved on its own, so the scan must not see it as a child of the folder either.
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ ParentId = _folderId,
+ IncludeAlternateVersions = true
+ });
+
+ Assert.DoesNotContain(result, i => i.Id.Equals(_ownedVersionId));
+ }
+
+ private static BaseItemEntity CreateMovie(Guid id, string typeName, string name, string path, Guid? primaryVersionId, Guid? ownerId)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = typeName,
+ Name = name,
+ Path = path,
+ ParentId = _folderId,
+ TopParentId = _folderId,
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"),
+ PrimaryVersionId = primaryVersionId,
+ OwnerId = ownerId,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
index 535961a66c..9238ec9fd1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -13,13 +13,18 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
{
+ private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
private readonly BaseItemRepository _repository;
private readonly string _movieTypeName;
+ private readonly string _folderTypeName;
public BaseItemRepositoryGroupingTests()
{
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder];
_repository = CreateBaseItemRepository(itemTypeLookup);
}
@@ -67,6 +72,118 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
Assert.Equal(firstId, item.Id);
}
+ [Fact]
+ public void GetItemList_LibraryWithoutThePrimaryOfTheGroup_KeepsTheVersionVisible()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+ var sameLibraryPrimaryId = Guid.Parse("55555555-5555-5555-5555-555555555555");
+ var sameLibraryVersionId = Guid.Parse("66666666-6666-6666-6666-666666666666");
+
+ SeedCrossLibraryGroup(primaryId, versionId, sameLibraryPrimaryId, sameLibraryVersionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId));
+
+ // The version stands in for the group in the library it lives in, because its primary is in
+ // a library of its own; a group merged inside this library still collapses onto its primary.
+ Assert.Contains(result, i => i.Id.Equals(versionId));
+ Assert.Contains(result, i => i.Id.Equals(sameLibraryPrimaryId));
+ Assert.DoesNotContain(result, i => i.Id.Equals(sameLibraryVersionId));
+ Assert.DoesNotContain(result, i => i.Id.Equals(primaryId));
+ }
+
+ [Fact]
+ public void GetItemList_LibraryHoldingThePrimary_ReturnsThePrimary()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movie4KLibraryId));
+
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_BothLibrariesOfACrossLibraryGroup_ReturnsItOnce()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId, _movie4KLibraryId));
+
+ // With both libraries in scope the presentation key grouping collapses the version.
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItems_LibraryWithoutThePrimaryOfTheGroup_CountsWhatItLists()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var listed = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId)).Count;
+
+ var query = CreateLibraryQuery(_movieLibraryId);
+ query.EnableTotalRecordCount = true;
+ query.Limit = 1;
+
+ // The total the client pages against has to agree with the listing.
+ Assert.Equal(1, listed);
+ Assert.Equal(listed, _repository.GetItems(query).TotalRecordCount);
+ }
+
+ private static InternalItemsQuery CreateLibraryQuery(params Guid[] topParentIds)
+ {
+ return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie],
+ TopParentIds = topParentIds
+ };
+ }
+
+ private void SeedCrossLibraryGroup(
+ Guid primaryId,
+ Guid versionId,
+ Guid? sameLibraryPrimaryId = null,
+ Guid? sameLibraryVersionId = null)
+ {
+ using var ctx = CreateDbContext();
+ ctx.BaseItems.Add(CreateFolderEntity(_movieLibraryId, "Movies"));
+ ctx.BaseItems.Add(CreateFolderEntity(_movie4KLibraryId, "Movies-4K"));
+
+ // The 4K version heads the group and lives in a library of its own.
+ ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie - 4K", primaryId.ToString("N"), null, _movie4KLibraryId));
+ ctx.BaseItems.Add(CreateMovieEntity(versionId, "Movie", primaryId.ToString("N"), primaryId, _movieLibraryId));
+
+ if (sameLibraryPrimaryId.HasValue && sameLibraryVersionId.HasValue)
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(sameLibraryPrimaryId.Value, "Other - 4K", sameLibraryPrimaryId.Value.ToString("N"), null, _movieLibraryId));
+ ctx.BaseItems.Add(CreateMovieEntity(sameLibraryVersionId.Value, "Other", sameLibraryPrimaryId.Value.ToString("N"), sameLibraryPrimaryId.Value, _movieLibraryId));
+ }
+
+ ctx.SaveChanges();
+ }
+
+ private BaseItemEntity CreateFolderEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _folderTypeName,
+ Name = name,
+ Path = "/" + name,
+ IsFolder = true
+ };
+ }
+
private static InternalItemsQuery CreateQuery()
{
// IncludeOwnedItems keeps the alternate version rows in the query so the
@@ -78,13 +195,15 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
};
}
- private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId)
+ private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId, Guid? libraryId = null)
{
return new BaseItemEntity
{
Id = id,
Type = _movieTypeName,
Name = name,
+ ParentId = libraryId,
+ TopParentId = libraryId,
PresentationUniqueKey = presentationKey,
PrimaryVersionId = primaryVersionId,
MediaType = "Video",
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
index 91148501ce..039693c432 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
@@ -104,6 +104,50 @@ public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture
}
[Fact]
+ public void GetTagNames_GroupsAndFiltersItemValues()
+ {
+ var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
+ var otherMovie = CreateMovieEntity(Guid.NewGuid(), "Other Movie");
+ var audio = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Excluded Audio",
+ MediaType = "Audio",
+ IsMovie = false,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var tag = CreateItemValue(ItemValueType.Tags, "Alpha", "alpha");
+ var duplicateTag = CreateItemValue(ItemValueType.Tags, "alpha", "alpha");
+ var otherTag = CreateItemValue(ItemValueType.Tags, "Beta", "beta");
+ var inheritedTag = CreateItemValue(ItemValueType.InheritedTags, "Inherited", "inherited");
+ var genre = CreateItemValue(ItemValueType.Genre, "Genre Leak", "genre leak");
+ var excludedTag = CreateItemValue(ItemValueType.Tags, "Excluded Tag", "excluded tag");
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(movie, otherMovie, audio);
+ context.ItemValues.AddRange(tag, duplicateTag, otherTag, inheritedTag, genre, excludedTag);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, tag),
+ CreateMap(movie, duplicateTag),
+ CreateMap(otherMovie, otherTag),
+ CreateMap(movie, inheritedTag),
+ CreateMap(movie, genre),
+ CreateMap(audio, excludedTag));
+ context.SaveChanges();
+ }
+
+ var result = _repository.GetTagNames(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal(["Alpha", "Beta"], result);
+ }
+
+ [Fact]
public void GetGenreNames_GroupsAndFiltersMappedItemValues()
{
var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
index 0958512b1a..a9548a6d13 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
@@ -32,6 +32,8 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid();
private readonly Guid _unplayedSeries = Guid.NewGuid();
+ private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid();
+ private readonly Guid _partiallyPlayedSeries = Guid.NewGuid();
public BaseItemRepositoryPlayedVersionTests()
{
@@ -68,8 +70,29 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
[Fact]
public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
{
- Assert.Equal(new HashSet<Guid> { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true));
- Assert.Equal(new HashSet<Guid> { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false));
+ Assert.Equal(
+ new HashSet<Guid> { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions },
+ Ids(BaseItemKind.Series, isPlayed: true));
+ Assert.Equal(
+ new HashSet<Guid> { _unplayedSeries, _partiallyPlayedSeries },
+ Ids(BaseItemKind.Series, isPlayed: false));
+ }
+
+ [Fact]
+ public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
+ {
+ Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true));
+ Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true));
+ }
+
+ [Fact]
+ public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion()
+ {
+ var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true });
+
+ // Nothing is left to watch, so the series is not half finished.
+ Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable);
+ Assert.Contains(_partiallyPlayedSeries, resumable);
}
private HashSet<Guid> Ids(BaseItemKind kind, bool isPlayed)
@@ -95,6 +118,9 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true);
AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false);
+ AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true);
+ AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false);
+
context.SaveChanges();
}
@@ -113,7 +139,33 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
{
var episodeId = Guid.NewGuid();
- context.BaseItems.Add(new BaseItemEntity
+ AddSeriesFolder(context, seriesId, name);
+
+ AddItem(context, episodeId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+
+ AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
+ }
+
+ // A watched first episode plus a second one that is either watched as its alternate version or not
+ // watched at all, which is what separates a finished series from a half watched one.
+ private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate)
+ {
+ AddSeriesFolder(context, seriesId, name);
+
+ var firstId = Guid.NewGuid();
+ AddItem(context, firstId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddPlayedUserData(context, firstId);
+
+ var secondId = Guid.NewGuid();
+ AddItem(context, secondId, EpisodeType, $"{name} 2");
+ context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate);
+ }
+
+ private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name)
+ => context.BaseItems.Add(new BaseItemEntity
{
Id = seriesId,
Type = SeriesType,
@@ -123,12 +175,6 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
IsFolder = true
});
- AddItem(context, episodeId, EpisodeType, $"{name} 1");
- context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
-
- AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
- }
-
private void AddItem(JellyfinDbContext context, Guid id, string type, string name)
=> context.BaseItems.Add(new BaseItemEntity
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
index ff683dc57a..787bb24150 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -201,6 +201,201 @@ public sealed class ItemCountServiceTests : IDisposable
}
[Fact]
+ public void GetCounts_PlayedAlternateVersion_CountThePrimaryAsPlayed()
+ {
+ var user = new User("alt-version-test", "provider", "reset");
+ var seriesId = Guid.NewGuid();
+ var primaryId = Guid.NewGuid();
+ var alternateId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ var series = CreateItem(seriesId);
+ series.PresentationUniqueKey = "alt-version-series";
+ context.BaseItems.Add(series);
+
+ context.BaseItems.Add(CreateLeaf(primaryId));
+ var alternate = CreateLeaf(alternateId);
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+ context.SaveChanges();
+
+ // Only the primary is counted as a leaf, as ApplyAccessFiltering leaves it in production.
+ AddAncestor(context, primaryId, seriesId);
+
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = primaryId,
+ ChildId = alternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ // The file that was watched is the alternate, so the primary carries no played row.
+ context.UserData.Add(new UserData
+ {
+ ItemId = alternateId,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ // The per-item paths have to agree with the batch one, which the DTO uses interchangeably.
+ Assert.Equal(1, _service.GetPlayedCount(filter, seriesId));
+ Assert.Equal((1, 1), _service.GetPlayedAndTotalCount(filter, seriesId));
+ Assert.Equal((1, 1), _service.GetPlayedAndTotalCountBatch([seriesId], user)[seriesId]);
+ }
+
+ [Fact]
+ public void GetCounts_MultiVersionMovie_CountPlaybackOfAnyVersion()
+ {
+ // Two movies held as two files each: the primary the collection links, and an alternate version
+ // linked to it. One movie was watched on its alternate, which is where playback of a second cut
+ // lands; the other was not watched at all.
+ var user = new User("alt-version-test", "provider", "reset");
+ var boxSetId = Guid.NewGuid();
+ var libraryId = Guid.NewGuid();
+ var watchedPrimaryId = Guid.NewGuid();
+ var watchedAlternateId = Guid.NewGuid();
+ var unwatchedPrimaryId = Guid.NewGuid();
+ var unwatchedAlternateId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ var boxSet = CreateItem(boxSetId);
+ boxSet.PresentationUniqueKey = "alt-version-box-set";
+ context.BaseItems.Add(boxSet);
+
+ var library = CreateItem(libraryId);
+ library.PresentationUniqueKey = "alt-version-library";
+ context.BaseItems.Add(library);
+
+ foreach (var (primaryId, alternateId) in
+ new[] { (watchedPrimaryId, watchedAlternateId), (unwatchedPrimaryId, unwatchedAlternateId) })
+ {
+ context.BaseItems.Add(CreateLeaf(primaryId));
+
+ var alternate = CreateLeaf(alternateId);
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+ }
+
+ context.SaveChanges();
+
+ context.LinkedChildren.AddRange(
+ new LinkedChildEntity
+ {
+ ParentId = boxSetId,
+ ChildId = watchedPrimaryId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = boxSetId,
+ ChildId = unwatchedPrimaryId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 1
+ },
+ new LinkedChildEntity
+ {
+ ParentId = watchedPrimaryId,
+ ChildId = watchedAlternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = unwatchedPrimaryId,
+ ChildId = unwatchedAlternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ AddAncestor(context, watchedPrimaryId, libraryId);
+ AddAncestor(context, unwatchedPrimaryId, libraryId);
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = watchedAlternateId,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ // A version group is one item to count, and the alternate's playback makes that item played -
+ // as it already does for the played flag the primary itself reports.
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCountFromLinkedChildren(filter, boxSetId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCountBatch([boxSetId], user)[boxSetId]);
+
+ // The ancestor-based paths answer the same for the library the primaries sit in.
+ Assert.Equal(1, _service.GetPlayedCount(filter, libraryId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, libraryId));
+ }
+
+ [Fact]
+ public void GetChildCountBatch_NoUser_StillCollapsesAlternateVersions()
+ {
+ // Both files of a merged movie sit in the folder. With a user it is access filtering that
+ // drops the alternate; with no user nothing else would, and the folder would report two
+ // children for the one title a viewer sees.
+ var folderId = Guid.NewGuid();
+ var primaryId = Guid.NewGuid();
+ var alternateId = Guid.NewGuid();
+ var extraId = Guid.NewGuid();
+ var ownedId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(CreateItem(folderId));
+
+ var primary = CreateLeaf(primaryId);
+ primary.ParentId = folderId;
+ context.BaseItems.Add(primary);
+
+ var alternate = CreateLeaf(alternateId);
+ alternate.ParentId = folderId;
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+
+ // An extra carries an owner and an extra type, and stays a child of its own.
+ var extra = CreateLeaf(extraId);
+ extra.ParentId = folderId;
+ extra.OwnerId = primaryId;
+ extra.ExtraType = BaseItemExtraType.Trailer;
+ context.BaseItems.Add(extra);
+
+ // An owned item that is not an extra belongs to its owner, not to the folder.
+ var owned = CreateLeaf(ownedId);
+ owned.ParentId = folderId;
+ owned.OwnerId = primaryId;
+ context.BaseItems.Add(owned);
+
+ context.SaveChanges();
+ }
+
+ Assert.Equal(2, _service.GetChildCountBatch([folderId], null)[folderId]);
+ }
+
+ [Fact]
public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys()
{
var seriesA = Guid.NewGuid();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs
new file mode 100644
index 0000000000..c15ea09965
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the invariant that a video linked as an alternate version also carries the
+/// PrimaryVersionId the item queries hide it by, including when it was already a library
+/// item in its own right before it became a version.
+/// </summary>
+public sealed class ItemPersistenceAlternateVersionTests : SqliteDbTestFixture
+{
+ private const string PrimaryPath = "/movies/Movie/Movie - 4K.mkv";
+ private const string VersionPath = "/movies/Movie/Movie - 1080p.mkv";
+
+ private readonly ItemPersistenceService _service;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IServerConfigurationManager? _previousConfigurationManager;
+ private readonly IRecordingsManager? _previousRecordingsManager;
+
+ public ItemPersistenceAlternateVersionTests()
+ {
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousConfigurationManager = BaseItem.ConfigurationManager;
+ _previousRecordingsManager = Video.RecordingsManager;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>()))
+ .Returns([]);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configurationManager.Object;
+
+ // Video.SourceType asks this whether the file is an in-progress recording.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
+ // Paths round-trip through the host's virtual path mapping on the way in and out.
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(h => h.ReverseVirtualPath(It.IsAny<string>())).Returns((string p) => p);
+ appHost.Setup(h => h.ExpandVirtualPath(It.IsAny<string>())).Returns((string p) => p);
+
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ appHost.Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ Video.RecordingsManager = _previousRecordingsManager!;
+ base.Dispose(disposing);
+ }
+
+ [Fact]
+ public void SaveItems_LocalAlternateVersionAlreadyAnItem_SetsPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ // The version was scanned as a standalone movie before it became a version, so it has a
+ // presentation key of its own and no PrimaryVersionId.
+ var version = CreateMovie(versionId, VersionPath);
+ version.PresentationUniqueKey = "standalone";
+ _service.SaveItems([version], CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId);
+ }
+
+ // Now the scan folds it into a primary, which is the item that gets saved.
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId)));
+ Assert.Equal(DbLinkedChildType.LocalAlternateVersion, link.ChildType);
+ Assert.Equal(versionId, link.ChildId);
+
+ var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId));
+ Assert.Equal(primaryId, stored.PrimaryVersionId);
+
+ // Presentation-key grouping has to collapse it onto the primary as well.
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey);
+ }
+ }
+
+ [Fact]
+ public void SaveItems_LinkedAlternateVersionAlreadyAnItem_SetsPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ _service.SaveItems([CreateMovie(versionId, VersionPath)], CancellationToken.None);
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LinkedAlternateVersions =
+ [
+ new LinkedChild { ItemId = versionId, Type = LinkedChildType.LinkedAlternateVersion }
+ ];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId)));
+ Assert.Equal(DbLinkedChildType.LinkedAlternateVersion, link.ChildType);
+ Assert.Equal(primaryId, ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId);
+ }
+
+ [Fact]
+ public void SaveItems_VersionAlreadyPointingAtPrimary_LeavesItAlone()
+ {
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
+
+ var version = CreateMovie(versionId, VersionPath);
+ version.SetPrimaryVersionId(primaryId);
+ _service.SaveItems([version], CancellationToken.None);
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId));
+ Assert.Equal(primaryId, stored.PrimaryVersionId);
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey);
+ }
+
+ [Fact]
+ public void SaveItems_VideoListedAmongItsOwnVersions_KeepsItsOwnPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [PrimaryPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(primaryId)).PrimaryVersionId);
+ }
+
+ [Fact]
+ public void SaveItems_PromotedVersionStillPointingAtOldPrimary_DoesNotCreateACycle()
+ {
+ var promotedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var oldPrimaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+
+ _service.SaveItems([CreateMovie(oldPrimaryId, VersionPath)], CancellationToken.None);
+
+ // The rescan resolves this one as the primary of the group, but it still carries the pointer
+ // to the version it was promoted over.
+ var promoted = CreateMovie(promotedId, PrimaryPath);
+ promoted.SetPrimaryVersionId(oldPrimaryId);
+ promoted.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([promoted], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+
+ // Pointing the old primary back would hide both, and with them the whole group.
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(oldPrimaryId)).PrimaryVersionId);
+ Assert.Equal(oldPrimaryId, ctx.BaseItems.First(e => e.Id.Equals(promotedId)).PrimaryVersionId);
+ }
+
+ private static Movie CreateMovie(Guid id, string path) => new()
+ {
+ Id = id,
+ Name = "Movie",
+ Path = path
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs
new file mode 100644
index 0000000000..e2bdd9e0b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// DeleteItem has to hand SQLite one statement that already contains everything the foreign keys
+/// on BaseItems require, because FK_BaseItems_BaseItems_OwnerId is NO ACTION: anything left behind
+/// pointing at a deleted row fails the whole delete with SQLite error 19.
+/// </summary>
+public sealed class ItemPersistenceDeleteItemTests : SqliteDbTestFixture
+{
+ private static readonly Guid _owner = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
+ private static readonly Guid _extra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001");
+ private static readonly Guid _extraOfExtra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000002");
+ private static readonly Guid _child = Guid.Parse("cccccccc-0000-0000-0000-000000000001");
+ private static readonly Guid _extraOfChild = Guid.Parse("eeeeeeee-0000-0000-0000-000000000003");
+
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistenceDeleteItemTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ new Mock<IServerApplicationHost>().Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_OwnerIdChain_DeletesWholeChain()
+ {
+ // An extra that owns an extra of its own. Real libraries carry these in bulk, and a single
+ // expansion pass over OwnerId leaves the second level behind.
+ Seed(
+ (_owner, null, null),
+ (_extra, _owner, null),
+ (_extraOfExtra, _extra, null));
+
+ _service.DeleteItem([_owner]);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra) || e.Id.Equals(_extraOfExtra)));
+ }
+
+ [Fact]
+ public void DeleteItem_ExtraOwnedByCascadedChild_DeletesExtraToo()
+ {
+ // The child goes away through FK_BaseItems_BaseItems_ParentId's ON DELETE CASCADE whether or
+ // not it is listed, so an extra owned by that child has to be listed with it.
+ Seed(
+ (_owner, null, null),
+ (_child, null, _owner),
+ (_extraOfChild, _child, null));
+
+ _service.DeleteItem([_owner]);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_child) || e.Id.Equals(_extraOfChild)));
+ }
+
+ [Fact]
+ public void DeleteItem_OwnershipCycle_Terminates()
+ {
+ // A malformed pair that owns each other must not spin the closure loop forever.
+ Seed((_owner, null, null), (_extra, _owner, null));
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Single(e => e.Id.Equals(_owner)).OwnerId = _extra;
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([_owner]);
+
+ using var assertContext = CreateDbContext();
+ Assert.Empty(assertContext.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra)));
+ }
+
+ private void Seed(params (Guid Id, Guid? OwnerId, Guid? ParentId)[] items)
+ {
+ using var context = CreateDbContext();
+
+ // Owners before the rows referencing them: the seed itself is foreign key checked.
+ foreach (var (id, ownerId, parentId) in items)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = "MediaBrowser.Controller.Entities.Video",
+ OwnerId = ownerId,
+ ParentId = parentId
+ });
+
+ context.SaveChanges();
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs
new file mode 100644
index 0000000000..fc28025573
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemPersistencePeopleCleanupTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistencePeopleCleanupTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_RemovesUnusedPeopleForItemsDescendantsAndExtras()
+ {
+ var parent = CreateItem(isFolder: true);
+ var child = CreateItem();
+ child.ParentId = parent.Id;
+ var extra = CreateItem();
+ extra.OwnerId = child.Id;
+ var survivor = CreateItem();
+ var shared = CreatePerson("Shared person");
+ var unrelatedOrphan = CreatePerson("Unrelated orphan");
+ using (var context = CreateDbContext())
+ {
+ context.PeopleBaseItemMap.AddRange(
+ Map(parent, CreatePerson("Parent credit")),
+ Map(child, CreatePerson("Child credit")),
+ Map(extra, CreatePerson("Extra credit")),
+ Map(child, shared),
+ Map(survivor, shared));
+ context.Peoples.Add(unrelatedOrphan);
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = child.Id,
+ Item = child,
+ ParentItemId = parent.Id,
+ ParentItem = parent
+ });
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([parent.Id]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id);
+ Assert.Equal(survivor.Id, Assert.Single(after.PeopleBaseItemMap).ItemId);
+ Assert.Equal(new[] { shared.Id, unrelatedOrphan.Id }.Order(), after.Peoples.Select(e => e.Id).Order());
+ }
+
+ private static BaseItemEntity CreateItem(bool isFolder = false) => new()
+ {
+ Id = Guid.NewGuid(),
+ Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!,
+ IsFolder = isFolder
+ };
+
+ private static People CreatePerson(string name) => new()
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ PersonType = "Actor"
+ };
+
+ private static PeopleBaseItemMap Map(BaseItemEntity item, People person) => new()
+ {
+ ItemId = item.Id,
+ Item = item,
+ PeopleId = person.Id,
+ People = person,
+ Role = string.Empty
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
new file mode 100644
index 0000000000..8ed3c61a59
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one
+/// whose alternate carries the played row, so an episode already seen must not be offered again.
+/// </summary>
+public sealed class NextUpServiceTests : SqliteDbTestFixture
+{
+ private const string SeriesKey = "next-up-series";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+
+ private readonly NextUpService _service;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ private readonly Guid _playedViaAlternate = Guid.NewGuid();
+ private readonly Guid _unplayed = Guid.NewGuid();
+
+ public NextUpServiceTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ _service = new NextUpService(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ CreateBaseItemRepository(itemTypeLookup));
+ }
+
+ [Fact]
+ public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt()
+ {
+ var batch = _service.GetNextUpEpisodesBatch(
+ new InternalItemsQuery(_user),
+ [SeriesKey],
+ includeSpecials: false,
+ includeWatchedForRewatching: false)[SeriesKey];
+
+ Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id);
+ Assert.Equal(_unplayed, batch.NextUp?.Id);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.Users.Add(_user);
+
+ AddEpisode(context, _playedViaAlternate, 1);
+ AddEpisode(context, _unplayed, 2);
+
+ // The second file of the first episode, and the only row the playback was recorded against.
+ // It presents under its primary's key, which is what keeps it out of the candidate list.
+ var alternateId = Guid.NewGuid();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = alternateId,
+ Type = EpisodeType,
+ Name = "Episode 1 4K",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = 1,
+ PresentationUniqueKey = _playedViaAlternate.ToString("N"),
+ PrimaryVersionId = _playedViaAlternate
+ });
+
+ context.SaveChanges();
+
+ // The link the scanner writes alongside PrimaryVersionId, and the hop the played state
+ // reaches the alternate through.
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _playedViaAlternate,
+ ChildId = alternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = alternateId,
+ UserId = _user.Id,
+ CustomDataKey = alternateId.ToString("N"),
+ Played = true,
+ Item = null!,
+ User = null!
+ });
+
+ context.SaveChanges();
+ }
+
+ private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = EpisodeType,
+ Name = $"Episode {indexNumber}",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = indexNumber,
+ PresentationUniqueKey = id.ToString("N")
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs
new file mode 100644
index 0000000000..b925f98197
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class PeopleUpdateQueryTests : SqliteDbTestFixture
+{
+ private readonly CommandRecorder _recorder;
+ private readonly Guid _itemId = Guid.NewGuid();
+ private readonly PeopleRepository _people;
+
+ public PeopleUpdateQueryTests()
+ : this(new CommandRecorder())
+ {
+ }
+
+ private PeopleUpdateQueryTests(CommandRecorder recorder)
+ : base(recorder)
+ {
+ _recorder = recorder;
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _itemId,
+ Name = "Movie",
+ Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie]
+ });
+ context.SaveChanges();
+ _people = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), Mock.Of<IItemQueryHelpers>());
+ }
+
+ [Theory]
+ [InlineData("Hero")]
+ [InlineData("HERO")]
+ public void UnchangedCredits_DoNotWriteOrLookUpAllPeople(string role)
+ {
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, Role = "Hero" }]);
+ _recorder.Commands.Clear();
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "actor", Type = PersonKind.Actor, Role = role }]);
+ Assert.Single(_recorder.Commands);
+ Assert.StartsWith("SELECT", _recorder.Commands[0].Sql, StringComparison.Ordinal);
+ using var context = CreateDbContext();
+ Assert.Equal("Hero", Assert.Single(context.PeopleBaseItemMap).Role);
+ }
+
+ [Fact]
+ public void SortOrderChange_IsPersisted()
+ {
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 1 }]);
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 2 }]);
+ using var context = CreateDbContext();
+ Assert.Equal(2, Assert.Single(context.PeopleBaseItemMap).SortOrder);
+ }
+
+ [Fact]
+ public void UpdatePeople_GeneratedSqlUsesPeopleNameIndex()
+ {
+ ApplyMigration(new Jellyfin.Server.Implementations.Migrations.AddPeopleNameLowerIndex());
+ _recorder.Commands.Clear();
+ _people.UpdatePeople(_itemId, [
+ new PersonInfo { Name = "Actor A", Type = PersonKind.Actor },
+ new PersonInfo { Name = "Actor B", Type = PersonKind.Actor }
+ ]);
+ var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("lower(\"p\".\"Name\")", StringComparison.Ordinal));
+ Assert.Contains(Explain(query), line => line.Contains("SEARCH p USING INDEX IX_Peoples_NameLower", StringComparison.Ordinal));
+ }
+
+ private void ApplyMigration(Migration migration)
+ {
+ using var context = CreateDbContext();
+ foreach (var operation in migration.UpOperations.Cast<SqlOperation>())
+ {
+ context.Database.ExecuteSqlRaw(operation.Sql);
+ }
+ }
+
+ private string[] Explain(RecordedCommand query)
+ {
+ using var context = CreateDbContext();
+ using var command = context.Database.GetDbConnection().CreateCommand();
+#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters.
+ command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql;
+#pragma warning restore CA2100
+ foreach (var value in query.Parameters)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = value.Name;
+ parameter.Value = value.Value;
+ command.Parameters.Add(parameter);
+ }
+
+ using var reader = command.ExecuteReader();
+ var plan = new List<string>();
+ while (reader.Read())
+ {
+ plan.Add(reader.GetString(3));
+ }
+
+ return plan.ToArray();
+ }
+
+ private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters);
+
+ private sealed class CommandRecorder : DbCommandInterceptor
+ {
+ public List<RecordedCommand> Commands { get; } = [];
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ private void Record(DbCommand command) => Commands.Add(new RecordedCommand(
+ command.CommandText,
+ command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
index cfc9c9496c..6da176b4f1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -26,7 +27,7 @@ public abstract class SqliteDbTestFixture : IDisposable
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
- protected SqliteDbTestFixture()
+ protected SqliteDbTestFixture(params IInterceptor[] interceptors)
{
ApplicationPaths = new Mock<IApplicationPaths>().Object;
@@ -35,6 +36,7 @@ public abstract class SqliteDbTestFixture : IDisposable
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
+ .AddInterceptors(interceptors)
.Options;
using var context = CreateDbContext();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
index a28c1d6dfb..f717d9b665 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
@@ -29,6 +29,7 @@ public class FindExtrasTests
{
private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager;
private readonly Mock<IFileSystem> _fileSystemMock;
+ private readonly Mock<IItemRepository> _itemRepositoryMock;
public FindExtrasTests()
{
@@ -36,8 +37,8 @@ public class FindExtrasTests
fixture.Register(() => new NamingOptions());
var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
- var itemRepository = fixture.Freeze<Mock<IItemRepository>>();
- itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
+ _itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>();
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
_fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
_fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
@@ -482,6 +483,69 @@ public class FindExtrasTests
}
[Fact]
+ public void FindExtras_ExtraNamedByLocalMetadata_KeepsItsNameOnRescan()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var directoryService = new DirectoryService(_fileSystemMock.Object);
+ var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Trailer", extra.Name);
+
+ // A local metadata file gives the extra a title of its own, which the refresh persists
+ extra.Name = "Cannes Teaser";
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra);
+
+ var rescanned = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Cannes Teaser", rescanned.Name);
+ }
+
+ [Fact]
+ public void FindExtras_ExtraKeptItsGeneratedName_IsRenumberedOnRescan()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer2.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var directoryService = new DirectoryService(_fileSystemMock.Object);
+ var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Trailer", extra.Name);
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra);
+
+ // A trailer sorting before the known one takes the first number, so the known one moves on
+ files.Add(new FileSystemMetadata
+ {
+ FullName = "/movies/Up (2009)/Up (2009)-trailer1.mkv",
+ IsDirectory = false
+ });
+
+ var rescanned = _libraryManager.FindExtras(owner, files, directoryService)
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ Assert.Equal("Trailer", rescanned["/movies/Up (2009)/Up (2009)-trailer1.mkv"]);
+ Assert.Equal("Trailer 2", rescanned["/movies/Up (2009)/Up (2009)-trailer2.mkv"]);
+ }
+
+ [Fact]
public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle()
{
var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs
new file mode 100644
index 0000000000..31109b2968
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager;
+
+public sealed class ResolveAlternateVersionTests : IDisposable
+{
+ private const string PrimaryPath = "/movies/Up/Up.mkv";
+ private const string AlternatePath = "/movies/Up/Up - 1080p.mkv";
+
+ private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager;
+ private readonly Mock<IItemPersistenceService> _persistenceServiceMock;
+ private readonly Folder _staleParent;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IMediaSourceManager? _previousMediaSourceManager;
+ private readonly IItemRepository? _previousItemRepository;
+
+ public ResolveAlternateVersionTests()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ fixture.Freeze<Mock<IServerConfigurationManager>>()
+ .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ _persistenceServiceMock = fixture.Freeze<Mock<IItemPersistenceService>>();
+ var itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>();
+ fixture.Freeze<Mock<IFileSystem>>()
+ .Setup(f => f.GetFileInfo(It.IsAny<string>()))
+ .Returns<string>(path => new FileSystemMetadata { FullName = path });
+
+ _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>()
+ .Do(s => s.AddParts(
+ fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
+ [],
+ fixture.Create<IEnumerable<IIntroProvider>>(),
+ fixture.Create<IEnumerable<IBaseItemComparer>>(),
+ fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
+ .Create();
+
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousMediaSourceManager = BaseItem.MediaSourceManager;
+ _previousItemRepository = BaseItem.ItemRepository;
+ BaseItem.LibraryManager = _libraryManager;
+
+ var mediaSourceManagerMock = new Mock<IMediaSourceManager>();
+ mediaSourceManagerMock.Setup(m => m.GetMediaStreams(It.IsAny<Guid>())).Returns([]);
+ mediaSourceManagerMock.Setup(m => m.GetMediaAttachments(It.IsAny<Guid>())).Returns([]);
+ BaseItem.MediaSourceManager = mediaSourceManagerMock.Object;
+
+ // A reloaded listing comes back empty, so a stale entry surviving is visible.
+ itemRepositoryMock.Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())).Returns([]);
+ BaseItem.ItemRepository = itemRepositoryMock.Object;
+
+ BaseItem.FileSystem ??= fixture.Create<IFileSystem>();
+ BaseItem.MediaSegmentManager ??= fixture.Create<IMediaSegmentManager>();
+ BaseItem.ConfigurationManager ??= fixture.Create<IServerConfigurationManager>();
+ Video.RecordingsManager ??= fixture.Create<IRecordingsManager>();
+
+ var primary = new Movie
+ {
+ Name = "Up",
+ Path = PrimaryPath,
+ LocalAlternateVersions = [AlternatePath],
+ Id = _libraryManager.GetNewItemId(PrimaryPath, typeof(Movie))
+ };
+
+ _staleParent = new Folder
+ {
+ Name = "Up",
+ Path = "/movies/Up",
+ Id = _libraryManager.GetNewItemId("/movies/Up", typeof(Folder))
+ };
+
+ var staleAlternate = new Video
+ {
+ Name = "Up - 1080p",
+ Path = AlternatePath,
+ OwnerId = primary.Id,
+ ParentId = _staleParent.Id,
+ Id = _libraryManager.GetNewItemId(AlternatePath, typeof(Video))
+ };
+ staleAlternate.SetPrimaryVersionId(primary.Id);
+
+ itemRepositoryMock
+ .Setup(i => i.RetrieveItem(It.IsAny<Guid>()))
+ .Returns<Guid>(id => id.Equals(primary.Id) ? primary
+ : id.Equals(staleAlternate.Id) ? staleAlternate
+ : id.Equals(_staleParent.Id) ? _staleParent
+ : null!);
+
+ StaleAlternateId = staleAlternate.Id;
+ }
+
+ private Guid StaleAlternateId { get; }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.MediaSourceManager = _previousMediaSourceManager!;
+ BaseItem.ItemRepository = _previousItemRepository!;
+ }
+
+ [Fact]
+ public void ResolveAlternateVersion_StaleWrongTypeItem_DropsRowWithoutResavingPrimary()
+ {
+ // The alternate is stored under the id of the generic Video type while its primary is a Movie.
+ _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null);
+
+ _persistenceServiceMock.Verify(
+ p => p.DeleteItem(It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 1 && ids[0].Equals(StaleAlternateId))),
+ Times.Once);
+
+ // Saving the primary is what re-enters this method before the stale row is gone.
+ _persistenceServiceMock.Verify(
+ p => p.SaveItems(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<CancellationToken>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public void ResolveAlternateVersion_StaleWrongTypeItem_DropsCachedParentListing()
+ {
+ _staleParent.Children = [new Video { Name = "Up - 1080p", Path = AlternatePath }];
+
+ _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null);
+
+ Assert.Empty(_staleParent.Children);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs
new file mode 100644
index 0000000000..6d0c491382
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.ScheduledTasks.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Tasks;
+using Moq;
+using Xunit;
+using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class LibraryManagerScanTests
+{
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task StartScanInBackground_QueuesOnlyWhenIdle(bool scanRunning)
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ var tasks = fixture.Freeze<Mock<ITaskManager>>();
+ var manager = fixture.Create<ServerLibraryManager>();
+ typeof(ServerLibraryManager).GetProperty(nameof(ServerLibraryManager.IsScanRunning))!.SetValue(manager, scanRunning);
+
+ await manager.StartScanInBackground().ConfigureAwait(true);
+
+ tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), scanRunning ? Times.Never() : Times.Once());
+ tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Never());
+ }
+
+ [Fact]
+ public async Task ValidateMediaLibrary_RestartsScheduledScan()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ var tasks = fixture.Freeze<Mock<ITaskManager>>();
+ var manager = fixture.Create<ServerLibraryManager>();
+
+ await manager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(true);
+
+ tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Once());
+ tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), Times.Never());
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index c80f899498..131cb23fa4 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -7,7 +7,9 @@ using Castle.Components.DictionaryAdapter;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
@@ -149,6 +151,73 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
}
+ [Theory]
+ // A remembered full track must not survive a switch to "only forced" (it falls through to
+ // the forced track here); a remembered forced track and "off" still must.
+ [InlineData(SubtitlePlaybackMode.OnlyForced, 2, 3)]
+ [InlineData(SubtitlePlaybackMode.OnlyForced, 3, 3)]
+ [InlineData(SubtitlePlaybackMode.OnlyForced, -1, -1)]
+ [InlineData(SubtitlePlaybackMode.Default, 2, 2)]
+ [InlineData(SubtitlePlaybackMode.Always, 2, 2)]
+ [InlineData(SubtitlePlaybackMode.Smart, 2, 2)]
+ [InlineData(SubtitlePlaybackMode.None, 2, null)]
+ public void SetDefaultSubtitleStreamIndex_RememberedSelection_RespectsSubtitleMode(
+ SubtitlePlaybackMode mode,
+ int rememberedIndex,
+ int? expectedIndex)
+ {
+ _mockUserDataManager
+ .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
+ .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = rememberedIndex });
+
+ var mediaInfo = new MediaSourceInfo
+ {
+ MediaStreams = new MediaStream[]
+ {
+ new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true },
+ new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true },
+ new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false },
+ new() { Index = 3, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = false, IsForced = true }
+ }
+ };
+
+ _user.SubtitleMode = mode;
+ _user.SubtitleLanguagePreference = string.Empty;
+ _user.RememberSubtitleSelections = true;
+ _user.AudioLanguagePreference = string.Empty;
+
+ _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
+
+ Assert.Equal(expectedIndex, mediaInfo.DefaultSubtitleStreamIndex);
+ }
+
+ [Fact]
+ public void SetDefaultSubtitleStreamIndex_OnlyForcedRemembersFullTrackWithNoForcedStream_SelectsNothing()
+ {
+ _mockUserDataManager
+ .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
+ .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = 2 });
+
+ var mediaInfo = new MediaSourceInfo
+ {
+ MediaStreams = new MediaStream[]
+ {
+ new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true },
+ new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true },
+ new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false }
+ }
+ };
+
+ _user.SubtitleMode = SubtitlePlaybackMode.OnlyForced;
+ _user.SubtitleLanguagePreference = string.Empty;
+ _user.RememberSubtitleSelections = true;
+ _user.AudioLanguagePreference = string.Empty;
+
+ _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
+
+ Assert.Null(mediaInfo.DefaultSubtitleStreamIndex);
+ }
+
[Fact]
public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
{
@@ -195,6 +264,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library
}
[Fact]
+ public void GetStaticMediaSources_ItemWithoutMediaSources_ThrowsArgumentException()
+ {
+ // A container queued by mistake is a bad request, not a server fault.
+ Assert.Throws<ArgumentException>(
+ () => _mediaSourceManager.GetStaticMediaSources(new MusicArtist { Id = Guid.NewGuid() }, false, _user));
+ }
+
+ [Fact]
public void GetStaticMediaSources_NoUser_DoesNotTouchUserData()
{
var (primary, _, _) = SetupVersionGroup();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
index e1346a8436..3aac3cdc43 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using Moq;
@@ -89,4 +90,92 @@ public class MovieResolverTests
Assert.All(result.Items, item => Assert.IsType<Movie>(item));
Assert.Single(((Video)result.Items[0]).LocalAlternateVersions);
}
+
+ [Fact]
+ public void AllExtrasTypesFolderNames_ContainsSampleSingularAndPlural()
+ {
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("sample"));
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("Sample"));
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("samples"));
+ }
+
+ [Theory]
+ [InlineData("Sample")]
+ [InlineData("sample")]
+ [InlineData("SAMPLE")]
+ [InlineData("samples")]
+ public void ResolvePath_MovieFolderWithSampleSubfolder_ResolvesToMovie(string sampleDirName)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+ libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false);
+
+ var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+ var args = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ libraryManager.Object)
+ {
+ Parent = new Folder(),
+ CollectionType = CollectionType.movies,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)",
+ IsDirectory = true
+ },
+ FileSystemChildren = new[]
+ {
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv",
+ Name = "Outer Colony (2026).mkv"
+ },
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/" + sampleDirName,
+ Name = sampleDirName,
+ IsDirectory = true
+ }
+ }
+ };
+
+ Assert.IsType<Movie>(resolver.ResolvePath(args));
+ }
+
+ [Fact]
+ public void ResolvePath_MovieFolderWithRealSubfolder_DoesNotResolveToSingleMovie()
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+ libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false);
+
+ var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+ var args = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ libraryManager.Object)
+ {
+ Parent = new Folder(),
+ CollectionType = CollectionType.movies,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)",
+ IsDirectory = true
+ },
+ FileSystemChildren = new[]
+ {
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv",
+ Name = "Outer Colony (2026).mkv"
+ },
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Feature",
+ Name = "Feature",
+ IsDirectory = true
+ }
+ }
+ };
+
+ Assert.Null(resolver.ResolvePath(args));
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
index 297f930205..421671b520 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
@@ -10,6 +10,7 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Tests.Item;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
@@ -26,9 +27,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library;
/// </summary>
public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
{
+ private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
private readonly MovieSimilarItemsProvider _provider;
+ private readonly Mock<ILibraryManager> _libraryManager = new();
private readonly User _user = new("test", "auth-provider", "reset-provider");
private readonly string _movieTypeName;
+ private readonly string _folderTypeName;
private readonly Guid _source = Guid.NewGuid();
private readonly Guid _sourceAlternate = Guid.NewGuid();
@@ -36,10 +42,19 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
private readonly Guid _similarAlternate = Guid.NewGuid();
private readonly Guid _unrelated = Guid.NewGuid();
+ // A second scenario, in two libraries and on a genre of its own, for the group whose primary the
+ // user may not be able to reach at all.
+ private readonly Guid _crossSource = Guid.NewGuid();
+ private readonly Guid _crossLibraryPrimary = Guid.NewGuid();
+ private readonly Guid _crossLibraryVersion = Guid.NewGuid();
+ private readonly Guid _sameLibraryPrimary = Guid.NewGuid();
+ private readonly Guid _sameLibraryVersion = Guid.NewGuid();
+
public MovieSimilarItemsProviderTests()
{
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!;
+ _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!;
using (var context = CreateDbContext())
{
@@ -53,7 +68,7 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
CreateDbContextFactory(),
CreateBaseItemRepository(itemTypeLookup),
serverConfigurationManager.Object,
- new Mock<ILibraryManager>().Object);
+ _libraryManager.Object);
}
[Fact]
@@ -80,10 +95,52 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
Assert.DoesNotContain(_sourceAlternate, items);
}
- private async Task<List<Guid>> GetSimilarItemsAsync()
+ [Fact]
+ public async Task GetSimilarItems_UserWithoutThePrimarysLibrary_OffersTheVersion()
+ {
+ // The user may only open the library the 1080p version is in, so its primary is out of reach
+ // and the version is all that is left to stand in for the group.
+ RestrictUserTo(_movieLibraryId);
+
+ var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
+
+ Assert.Contains(_crossLibraryVersion, items);
+ Assert.DoesNotContain(_crossLibraryPrimary, items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_UserWithBothLibraries_OffersThePrimaryOfTheGroupOnce()
+ {
+ RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
+
+ var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
+
+ Assert.Contains(_crossLibraryPrimary, items);
+ Assert.DoesNotContain(_crossLibraryVersion, items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_GroupMergedInsideOneLibrary_StillOffersOnlyThePrimary()
+ {
+ RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
+
+ var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
+
+ Assert.Contains(_sameLibraryPrimary, items);
+ Assert.DoesNotContain(_sameLibraryVersion, items);
+ }
+
+ private void RestrictUserTo(params Guid[] libraryIds)
+ {
+ _libraryManager
+ .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>()))
+ .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds);
+ }
+
+ private async Task<List<Guid>> GetSimilarItemsAsync(Guid? sourceId = null)
{
var results = await _provider.GetSimilarItemsAsync(
- new Movie { Id = _source, Name = "Source" },
+ new Movie { Id = sourceId ?? _source, Name = "Source" },
new SimilarItemsQuery { User = _user, Limit = 10, DtoOptions = new DtoOptions() },
CancellationToken.None).ConfigureAwait(false);
@@ -102,19 +159,52 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
var similarAlternate = AddMovie(context, _similarAlternate, "Similar 4K", primaryVersionId: _similar);
var unrelated = AddMovie(context, _unrelated, "Unrelated", primaryVersionId: null);
+ // The second scenario scores on a genre of its own, so it stays out of the results above.
+ var crossLibrary = CreateItemValue("Science Fiction", "science fiction");
+
+ AddLibrary(context, _movieLibraryId, "Movies");
+ AddLibrary(context, _movie4KLibraryId, "Movies-4K");
+
+ var crossSource = AddMovie(context, _crossSource, "Cross Source", primaryVersionId: null, libraryId: _movieLibraryId);
+
+ // The 4K version heads the group and lives in a library of its own.
+ var crossLibraryPrimary = AddMovie(context, _crossLibraryPrimary, "Coco 4K", primaryVersionId: null, libraryId: _movie4KLibraryId);
+ var crossLibraryVersion = AddMovie(context, _crossLibraryVersion, "Coco", primaryVersionId: _crossLibraryPrimary, libraryId: _movieLibraryId);
+
+ // A group merged inside one library, as a control.
+ var sameLibraryPrimary = AddMovie(context, _sameLibraryPrimary, "Up 4K", primaryVersionId: null, libraryId: _movieLibraryId);
+ var sameLibraryVersion = AddMovie(context, _sameLibraryVersion, "Up", primaryVersionId: _sameLibraryPrimary, libraryId: _movieLibraryId);
+
context.Users.Add(_user);
- context.ItemValues.AddRange(shared, other);
+ context.ItemValues.AddRange(shared, other, crossLibrary);
context.ItemValuesMap.AddRange(
CreateMap(source, shared),
CreateMap(sourceAlternate, shared),
CreateMap(similar, shared),
CreateMap(similarAlternate, shared),
- CreateMap(unrelated, other));
+ CreateMap(unrelated, other),
+ CreateMap(crossSource, crossLibrary),
+ CreateMap(crossLibraryPrimary, crossLibrary),
+ CreateMap(crossLibraryVersion, crossLibrary),
+ CreateMap(sameLibraryPrimary, crossLibrary),
+ CreateMap(sameLibraryVersion, crossLibrary));
context.SaveChanges();
}
- private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId)
+ private void AddLibrary(JellyfinDbContext context, Guid id, string name)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = _folderTypeName,
+ Name = name,
+ Path = "/" + name,
+ IsFolder = true
+ });
+ }
+
+ private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId, Guid? libraryId = null)
{
var item = new BaseItemEntity
{
@@ -122,6 +212,8 @@ public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
Type = _movieTypeName,
Name = name,
SortName = name,
+ ParentId = libraryId,
+ TopParentId = libraryId,
MediaType = "Video",
IsMovie = true,
IsFolder = false,
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs
new file mode 100644
index 0000000000..5aa770b9b3
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Emby.Server.Implementations.Library.Search;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.Implementations.Tests.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Covers what <see cref="SqlSearchProvider"/> returns for a version group merged across two
+/// libraries: the primary represents the group wherever it is visible, and the version stands in
+/// for it for a user who cannot open the library the primary lives in.
+/// </summary>
+public sealed class SqlSearchProviderTests : SqliteDbTestFixture
+{
+ private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ private static readonly Guid _primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ private static readonly Guid _versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ private readonly SqlSearchProvider _provider;
+ private readonly Mock<ILibraryManager> _libraryManager = new();
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ public SqlSearchProviderTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!;
+ var folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!;
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(_user);
+ context.BaseItems.Add(CreateLibrary(_movieLibraryId, folderTypeName, "Movies", "/movies"));
+ context.BaseItems.Add(CreateLibrary(_movie4KLibraryId, folderTypeName, "Movies-4K", "/movies-4k"));
+ context.BaseItems.Add(CreateMovie(_primaryId, movieTypeName, _movie4KLibraryId, null));
+ context.BaseItems.Add(CreateMovie(_versionId, movieTypeName, _movieLibraryId, _primaryId));
+ context.SaveChanges();
+ }
+
+ var userManager = new Mock<IUserManager>();
+ userManager.Setup(u => u.GetUserById(_user.Id)).Returns(_user);
+
+ _provider = new SqlSearchProvider(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ _libraryManager.Object,
+ userManager.Object,
+ CreateBaseItemRepository(itemTypeLookup));
+ }
+
+ [Fact]
+ public async Task SearchAsync_UserWithoutThePrimarysLibrary_FindsTheVersion()
+ {
+ RestrictUserTo(_movieLibraryId);
+
+ var hits = await SearchAsync().ConfigureAwait(true);
+
+ Assert.Equal([_versionId], hits);
+ }
+
+ [Fact]
+ public async Task SearchAsync_UserWithBothLibraries_FindsThePrimaryOnce()
+ {
+ RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
+
+ var hits = await SearchAsync().ConfigureAwait(true);
+
+ Assert.Equal([_primaryId], hits);
+ }
+
+ private void RestrictUserTo(params Guid[] libraryIds)
+ {
+ _libraryManager
+ .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>()))
+ .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds);
+ }
+
+ private async Task<List<Guid>> SearchAsync()
+ {
+ var results = await _provider.SearchAsync(
+ new SearchProviderQuery { SearchTerm = "coco", UserId = _user.Id, Limit = 10 },
+ CancellationToken.None).ConfigureAwait(false);
+
+ return results.Select(r => r.ItemId).ToList();
+ }
+
+ private static BaseItemEntity CreateLibrary(Guid id, string typeName, string name, string path)
+ => new()
+ {
+ Id = id,
+ Type = typeName,
+ Name = name,
+ Path = path,
+ IsFolder = true
+ };
+
+ private static BaseItemEntity CreateMovie(Guid id, string typeName, Guid libraryId, Guid? primaryVersionId)
+ => new()
+ {
+ Id = id,
+ Type = typeName,
+ Name = "Coco",
+ CleanName = "coco",
+ SortName = "Coco",
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false,
+ ParentId = libraryId,
+ TopParentId = libraryId,
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"),
+ PrimaryVersionId = primaryVersionId
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index d973076ed3..4e57f32b57 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -6,6 +6,7 @@ using BitFaster.Caching;
using Emby.Server.Implementations.Localization;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -180,7 +181,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
await localizationManager.LoadAll();
var ratings = localizationManager.GetParentalRatings().ToList();
- Assert.Equal(24, ratings.Count);
+ Assert.Equal(34, ratings.Count);
var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal));
Assert.NotNull(fsk);
@@ -199,6 +200,47 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
[InlineData("Rated: R", "US", 17, 0)]
[InlineData("Rated R", "US", 17, 0)]
[InlineData(" PG-13 ", "US", 13, 0)]
+ [InlineData("T", "IT", 0, null)]
+ [InlineData("VM6", "IT", 6, null)]
+ [InlineData("VM12", "IT", 12, null)]
+ [InlineData("VM14", "IT", 14, null)]
+ [InlineData("VM18", "IT", 18, null)]
+ [InlineData("IT-VM14", "IT", 14, null)] // TMDB style country prefix
+ [InlineData("IT-VM18", "IT", 18, null)]
+ [InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive
+ [InlineData("VM 18", "IT", 18, null)]
+ [InlineData("Vietato ai minori di 18 anni", "IT", 18, null)]
+ [InlineData("ATP", "AR", 0, null)]
+ [InlineData("SAM 13", "AR", 13, null)]
+ [InlineData("SAM 16", "AR", 16, null)]
+ [InlineData("SAM 18", "AR", 18, null)]
+ [InlineData("SAM13", "AR", 13, null)] // Written without a space
+ [InlineData("AR-SAM 16", "AR", 16, null)] // Country prefix stripped against the configured country
+ [InlineData("AR-SAM 13", "US", 13, null)] // Country prefix resolved via the separator fallback
+ [InlineData("AR-SAM 18", "US", 18, null)]
+ [InlineData("AR-SAM13", "US", 13, null)]
+ [InlineData("SAM 18 C", "AR", 1001, null)] // Condicionada, same as "C"
+ [InlineData("Interdit aux moins de 12 ans", "FR", 12, null)]
+ [InlineData("Interdit aux moins de 18 ans", "FR", 18, null)]
+ [InlineData("X 18+", "AU", 1000, 0)] // Official spelling of the Australian X rating
+ [InlineData("X18+", "AU", 1000, 0)]
+ [InlineData("FSK18", "DE", 18, null)] // Written without a space
+ [InlineData("ab 18", "DE", 18, null)] // Written as the minimum age
+ [InlineData("DE:ab 6", "DE", 6, null)]
+ [InlineData("–12", "FR", 12, null)] // The CNC writes its minimum ages with an en dash
+ [InlineData("–16", "FR", 16, null)]
+ [InlineData("–18", "FR", 18, null)]
+ [InlineData("SU", "ID", 0, null)] // Indonesian broadcast classifications (KPI)
+ [InlineData("P", "ID", 2, null)]
+ [InlineData("A", "ID", 7, null)]
+ [InlineData("R", "ID", 13, null)]
+ [InlineData("D", "ID", 18, null)]
+ [InlineData("D18+", "ID", 18, null)] // Written with the minimum age, as broadcast since 2016
+ [InlineData("R-BO", "ID", 13, null)] // Parental guidance does not change the age group
+ [InlineData("Dewasa", "ID", 18, null)]
+ [InlineData("ID-D", "ID", 18, null)] // TMDB style country prefix
+ [InlineData("ID-D", "US", 18, null)] // Country prefix resolved via the separator fallback
+ [InlineData("P", "US", 1000, null)] // Ambiguous outside Indonesia, preferred as the Portuguese "Pornográfico"
public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration()
@@ -213,12 +255,42 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ // Rating strings are stored mixed-case in the *.json rating systems and must match regardless of casing
+ [InlineData("btl", "se", 0, null)] // Direct lookup, lowercase of "Btl"
+ [InlineData("BARNTILLÅTEN", "se", 0, null)] // Direct lookup, uppercase incl. diacritics
+ [InlineData("SE-BTL", "se", 0, null)] // Country prefix stripped against the configured country
+ [InlineData("SE-BTL", "us", 0, null)] // Country prefix resolved via the separator fallback
+ [InlineData("Från 7 År", "se", 7, null)] // Diacritic casing (json has "Från 7 år")
+ [InlineData("SE-Från 7 År", "us", 7, null)] // Same, via the separator fallback
+ [InlineData("fsk-16", "de", 16, null)] // Not Sweden specific: lowercase of "FSK-16"
+ public async Task GetRatingScore_IsCaseInsensitive_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
[InlineData("0", 0, null)]
[InlineData("1", 1, null)]
[InlineData("6", 6, null)]
[InlineData("12", 12, null)]
[InlineData("42", 42, null)]
[InlineData("9999", 9999, null)]
+ // The French CNC writes minimum ages as "-12" ("not for under 12s"). Parsing that as -12 would
+ // put the item below every MaxParentalRatingScore and bypass parental control entirely.
+ [InlineData("-10", 10, null)]
+ [InlineData("-12", 12, null)]
+ [InlineData("-16", 16, null)]
+ [InlineData("-18", 18, null)]
public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" });
@@ -241,6 +313,107 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Null(localizationManager.GetRatingScore("unrated"));
Assert.Null(localizationManager.GetRatingScore("Not Rated"));
Assert.Null(localizationManager.GetRatingScore("n/a"));
+ Assert.Null(localizationManager.GetRatingScore("N/A"));
+ Assert.Null(localizationManager.GetRatingScore(" n/a "));
+ }
+
+ [Theory]
+ // "NR" and "UR" are rating strings of some systems, so they must stay unrated when listed alongside others
+ [InlineData("NR / R", 17, 0)]
+ [InlineData("unrated / R", 17, 0)]
+ [InlineData("R / NR", 17, 0)]
+ public async Task GetRatingLevel_SkipsUnratedListEntries_Success(string value, int? expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "us" });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
+ // Ratings that contain a '/' themselves must not be split into a list of ratings
+ [InlineData("M/3", "pt", 3, null)]
+ [InlineData("M/12", "pt", 12, null)]
+ [InlineData("M/18", "pt", 18, null)]
+ [InlineData("PT-M/12", "pt", 12, null)] // TMDB style country prefix
+ [InlineData("M/12", "us", 12, null)] // Resolved through the all-systems fallback
+ [InlineData("U/A 13+", "in", 13, null)]
+ [InlineData("7/i", "es", 11, null)]
+ [InlineData("7/i/fig", "es", 11, null)]
+ [InlineData("18/fig", "es", 18, null)]
+ public async Task GetRatingScore_RatingContainingSlash_IsNotSplit(string value, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
+ // Providers list every spelling of a rating in a single field. Splitting such a list by its country
+ // prefix pairs the first entry's country with the last entry's rating, so it has to be split by '/' first.
+ [InlineData("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18", "de", 18, null)]
+ [InlineData("SE:15 / SE:15+ / SE:Från 15 år", "de", 15, null)]
+ [InlineData("FR:16 / US:12", "de", 16, null)] // The first entry that resolves wins
+ public async Task GetRatingScore_CountryPrefixedList_UsesFirstResolvingEntry(string value, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = countryCode });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Fact]
+ public async Task GetRatingScore_ResolvedCountryPrefixedList_DoesNotWarn()
+ {
+ var logger = new Mock<ILogger<LocalizationManager>>();
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object);
+ await localizationManager.LoadAll();
+
+ Assert.NotNull(localizationManager.GetRatingScore("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18"));
+
+ logger.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task GetRatingScore_ListWithoutKnownRating_WarnsOnce()
+ {
+ var logger = new Mock<ILogger<LocalizationManager>>();
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object);
+ await localizationManager.LoadAll();
+
+ Assert.Null(localizationManager.GetRatingScore("DE:Unbekannt / DE:Unsinn"));
+
+ logger.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
}
[Theory]
@@ -446,12 +619,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase));
}
- private LocalizationManager Setup(ServerConfiguration config)
+ private LocalizationManager Setup(ServerConfiguration config, ILogger<LocalizationManager>? logger = null)
{
var mockConfiguration = new Mock<IServerConfigurationManager>();
mockConfiguration.SetupGet(x => x.Configuration).Returns(config);
- return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>());
+ return new LocalizationManager(mockConfiguration.Object, logger ?? new NullLogger<LocalizationManager>());
}
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
new file mode 100644
index 0000000000..13081dcff9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SessionManager;
+
+public class PlayCommandQueueTests : IDisposable
+{
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public PlayCommandQueueTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ }
+
+ /// <summary>
+ /// A music genre tags its artists as well as their songs, and a by-name artist row is not a
+ /// folder, so the queue query cannot exclude it. Such an item has no media sources, and a
+ /// client that reaches it in the queue gets an error instead of the next track.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
+ [Fact]
+ public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems()
+ {
+ var genre = new MusicGenre { Id = Guid.NewGuid(), Name = "Reggaeton" };
+ var song = new Audio { Id = Guid.NewGuid(), Name = "Me Porto Bonito" };
+ var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "NATTI NATASHA" };
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(i => i.GetItemById(genre.Id)).Returns(genre);
+ libraryManager
+ .Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(new List<BaseItem> { artist, song });
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ Mock.Of<IEventManager>(),
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ libraryManager.Object,
+ Mock.Of<IUserManager>(),
+ Mock.Of<IMusicManager>(),
+ Mock.Of<IDtoService>(),
+ Mock.Of<IImageProcessor>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<IDeviceManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IHostApplicationLifetime>());
+
+ var session = await sessionManager.LogSessionActivity("app_name", "0.0.0", "device_id", "device_name", "127.0.0.1", null);
+
+ var command = new PlayRequest
+ {
+ ItemIds = new[] { genre.Id },
+ PlayCommand = PlayCommand.PlayNow
+ };
+
+ await sessionManager.SendPlayCommand(null, session.Id, command, CancellationToken.None);
+
+ Assert.Equal(new[] { song.Id }, command.ItemIds);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
index b1221f6f71..ecd8fafe80 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
@@ -1,12 +1,17 @@
using System;
using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
+using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group;
using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
@@ -55,11 +60,33 @@ public class SyncPlayManagerTests
Assert.False(harness.Manager.IsUserActive(harness.User.Id));
}
+ [Fact]
+ public async Task HandleRequest_GroupWaitsForAMemberThatNeverReportsReady_RecoversOnItsOwn()
+ {
+ var harness = new ManagerHarness(groupWaitTimeout: 200);
+ var second = harness.CreateSession("session-2");
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+
+ // Starting playback puts the group behind the ready barrier.
+ harness.Manager.HandleRequest(
+ harness.Session,
+ new PlayGroupRequest(new[] { Guid.NewGuid() }, 0, 0),
+ CancellationToken.None);
+ Assert.Equal(GroupStateType.Waiting, harness.Manager.GetGroup(harness.Session, info.GroupId).State);
+
+ // Neither session ever reports ready, so the group has to come out of the wait by itself.
+ Assert.Equal(
+ GroupStateType.Playing,
+ await harness.WaitForState(harness.Session, info.GroupId, GroupStateType.Playing));
+ }
+
private sealed class ManagerHarness
{
private readonly Mock<ISessionManager> _sessionManager = new();
- public ManagerHarness()
+ public ManagerHarness(long? groupWaitTimeout = null)
{
var userManager = new Mock<IUserManager>();
var libraryManager = new Mock<ILibraryManager>();
@@ -67,11 +94,26 @@ public class SyncPlayManagerTests
User = new User("tester", "auth-provider", "pwdreset-provider");
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User);
+ var item = new Mock<BaseItem>();
+ item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks;
+ libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object);
+
+ _sessionManager
+ .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+ _sessionManager
+ .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+
Manager = new SyncPlayManager(
NullLoggerFactory.Instance,
userManager.Object,
_sessionManager.Object,
- libraryManager.Object);
+ libraryManager.Object)
+ {
+ GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout
+ };
Session = CreateSession("session-1");
}
@@ -91,5 +133,17 @@ public class SyncPlayManagerTests
UserName = User.Username
};
}
+
+ public async Task<GroupStateType> WaitForState(SessionInfo session, Guid groupId, GroupStateType expected)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ GroupStateType state;
+ while ((state = Manager.GetGroup(session, groupId).State) != expected && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ return state;
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
index 0cccd5d4ca..d3cbc9b8be 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
@@ -20,6 +21,128 @@ namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
public class WaitingGroupStateTests
{
[Fact]
+ public void Ready_PlayingSessionReportsPositionFromBeforeSeek_IsCorrected()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(10).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ // One member seeks half an hour in.
+ state.HandleRequest(
+ new SeekGroupRequest(TimeSpan.FromMinutes(40).Ticks),
+ group,
+ GroupStateType.Playing,
+ harness.Second,
+ CancellationToken.None);
+
+ harness.Commands.Clear();
+
+ // The other member has not applied the seek yet and reports the old position, still playing.
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, TimeSpan.FromMinutes(10).Ticks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ // It must be seeked into position, not accepted as ready and handed a pause command
+ // scheduled the length of the seek into the future.
+ Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek);
+ Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Pause);
+ Assert.True(group.IsBuffering(), "session should still be considered buffering");
+ }
+
+ [Fact]
+ public void Ready_PlayingSessionRecoveringFromALongStall_IsNotSeeked()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(10).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ // The session reports it is buffering. No seek happens, so the group position stays put.
+ state.HandleRequest(
+ new BufferGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Playing,
+ harness.First,
+ CancellationToken.None);
+
+ harness.Commands.Clear();
+
+ // It recovers 45 seconds later, still behind, and must be waited for rather than seeked
+ // forward past content it already buffered.
+ var behind = group.PositionTicks - TimeSpan.FromSeconds(45).Ticks;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, behind, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek);
+ }
+
+ [Fact]
+ public void Ready_PlayingSessionSlightlyBehindGroup_IsStillTreatedAsCatchingUp()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ // A session that is a couple of seconds behind is genuinely recovering, and the group
+ // is expected to wait for it rather than seek it around.
+ group.PositionTicks = TimeSpan.FromMinutes(30).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, true);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+ harness.Commands.Clear();
+
+ var clientPosition = group.PositionTicks - TimeSpan.FromSeconds(2).Ticks;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, clientPosition, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek);
+ Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Pause);
+ }
+
+ [Fact]
+ public void Ready_PausedSessionOutOfPosition_IsStillCorrected()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(30).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, true);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+ harness.Commands.Clear();
+
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek);
+ }
+
+ [Fact]
public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds()
{
var harness = new GroupHarness();
@@ -81,9 +204,131 @@ public class WaitingGroupStateTests
Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1));
}
+ [Fact]
+ public async Task SessionJoined_JoinerNeverReportsReady_GroupResumesWithoutIt()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ // A session joins while the group is playing: the group pauses and waits for it.
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ // The joiner's player aborts and never reports ready. Without a bounded wait the whole
+ // group stays paused forever.
+ await harness.WaitForState(GroupStateType.Playing);
+
+ // Late buffer reports from the session that missed the deadline must not drag the group
+ // back into waiting.
+ group.HandleRequest(
+ joiner,
+ new BufferGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId),
+ CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ }
+
+ [Fact]
+ public async Task SessionJoined_GroupWasPaused_TimeoutLeavesTheGroupPaused()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+
+ // The group has been sitting paused for a while before anyone joins.
+ group.LastActivity = DateTime.UtcNow.AddMinutes(-2);
+ group.SetState(new PausedGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ // A group that was paused must not start playing because a member failed to report ready.
+ await harness.WaitForState(GroupStateType.Paused);
+
+ // Giving up on the joiner must not move the playback position of an already paused group.
+ Assert.Equal(TimeSpan.FromMinutes(5).Ticks, group.PositionTicks);
+
+ // Every member has to be told the group is no longer waiting.
+ var recipients = harness.StateUpdates
+ .Where(update => update.Update.State == GroupStateType.Paused)
+ .Select(update => update.SessionId)
+ .ToList();
+ Assert.Contains(harness.First.Id, recipients);
+ Assert.Contains(harness.Second.Id, recipients);
+ Assert.Contains(joiner.Id, recipients);
+ }
+
+ [Fact]
+ public async Task Ready_ReportedBeforeTheDeadline_GroupDoesNotGiveUpOnAnyone()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ group.HandleRequest(
+ joiner,
+ new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ CancellationToken.None);
+
+ // Everyone reported ready, so no deadline is left to trip and force a spurious unpause.
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ Assert.Null(group.GroupWaitDeadline);
+
+ var until = DateTime.UtcNow.AddMilliseconds(3 * 200);
+ while (DateTime.UtcNow < until)
+ {
+ harness.PumpGroupWaitTimeout();
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ }
+
+ [Fact]
+ public async Task SetPlaylistItem_AfterATimeout_GroupWaitsForEveryoneAgain()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+ await harness.WaitForState(GroupStateType.Playing);
+
+ // Giving up on a session lasts only until the group changes what it is playing.
+ group.HandleRequest(
+ harness.First,
+ new SetPlaylistItemGroupRequest(harness.PlaylistItemId),
+ CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+ Assert.NotNull(group.GroupWaitDeadline);
+ }
+
private sealed class GroupHarness
{
- public GroupHarness()
+ private readonly ISessionManager _sessionManager;
+ private readonly Guid _userId;
+
+ public GroupHarness(long? groupWaitTimeout = null)
{
var userManager = new Mock<IUserManager>();
var sessionManager = new Mock<ISessionManager>();
@@ -99,31 +344,29 @@ public class WaitingGroupStateTests
sessionManager
.Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
+ .Callback<string, SendCommand, CancellationToken>((_, command, _) => Commands.Add(command))
.Returns(Task.CompletedTask);
sessionManager
.Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Callback((string sessionId, GroupUpdate<GroupStateUpdate> update, CancellationToken _) => StateUpdates.Add((sessionId, update.Data)))
.Returns(Task.CompletedTask);
Group = new SyncPlayGroup(
NullLoggerFactory.Instance,
userManager.Object,
sessionManager.Object,
- libraryManager.Object);
-
- First = new SessionInfo(sessionManager.Object, NullLogger.Instance)
- {
- Id = "first",
- UserId = user.Id,
- UserName = "first"
- };
- Second = new SessionInfo(sessionManager.Object, NullLogger.Instance)
+ libraryManager.Object)
{
- Id = "second",
- UserId = user.Id,
- UserName = "second"
+ GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout
};
+ _sessionManager = sessionManager.Object;
+ _userId = user.Id;
+
+ First = NewSession("first");
+ Second = NewSession("second");
+
Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None);
Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None);
Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0);
@@ -132,10 +375,48 @@ public class WaitingGroupStateTests
public SyncPlayGroup Group { get; }
+ public List<(string SessionId, GroupStateUpdate Update)> StateUpdates { get; } = new();
+
public SessionInfo First { get; }
public SessionInfo Second { get; }
public Guid PlaylistItemId { get; }
+
+ public List<SendCommand> Commands { get; } = new List<SendCommand>();
+
+ // Mirrors the sweep SyncPlayManager runs on a timer.
+ public void PumpGroupWaitTimeout()
+ {
+ var group = Group;
+
+ // Group lock required as Group is not thread-safe.
+ lock (group)
+ {
+ group.HandleGroupWaitTimeout(CancellationToken.None);
+ }
+ }
+
+ public async Task WaitForState(GroupStateType expected)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ while (Group.GetInfo().State != expected && DateTime.UtcNow < deadline)
+ {
+ PumpGroupWaitTimeout();
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ Assert.Equal(expected, Group.GetInfo().State);
+ }
+
+ public SessionInfo NewSession(string id)
+ {
+ return new SessionInfo(_sessionManager, NullLogger.Instance)
+ {
+ Id = id,
+ UserId = _userId,
+ UserName = id
+ };
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs
new file mode 100644
index 0000000000..5bb5081b60
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events;
+using Jellyfin.Data.Queries;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Entities.Security;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Querying;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+public class DeviceAccessHostTests
+{
+ [Fact]
+ public async Task OnUserUpdated_LogoutThrows_DoesNotEscapeToThreadPool()
+ {
+ var user = new User("test", "default", "default");
+ var device = new Device(user.Id, "app", "1.0", "device", "device-id");
+
+ var deviceManager = new Mock<IDeviceManager>();
+ deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>()))
+ .Returns(new QueryResult<Device>(new[] { device }));
+ deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false);
+
+ var sessionManager = new Mock<ISessionManager>();
+ sessionManager.Setup(s => s.Logout(It.IsAny<Device>()))
+ .ThrowsAsync(new ObjectDisposedException(nameof(ISessionManager)));
+
+ var userManager = new Mock<IUserManager>();
+ var host = new DeviceAccessHost(
+ userManager.Object,
+ deviceManager.Object,
+ sessionManager.Object,
+ NullLogger<DeviceAccessHost>.Instance);
+ await host.StartAsync(TestContext.Current.CancellationToken);
+
+ var context = new CapturingSynchronizationContext();
+ var previous = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(context);
+ try
+ {
+ userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user));
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previous);
+ }
+
+ Assert.Empty(context.Exceptions);
+ }
+
+ [Fact]
+ public async Task OnUserUpdated_DeviceNoLongerAllowed_LogsOutDevice()
+ {
+ var user = new User("test", "default", "default");
+ var device = new Device(user.Id, "app", "1.0", "device", "device-id");
+
+ var deviceManager = new Mock<IDeviceManager>();
+ deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>()))
+ .Returns(new QueryResult<Device>(new[] { device }));
+ deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false);
+
+ var loggedOut = new TaskCompletionSource();
+ var sessionManager = new Mock<ISessionManager>();
+ sessionManager.Setup(s => s.Logout(It.IsAny<Device>()))
+ .Callback(() => loggedOut.TrySetResult())
+ .Returns(Task.CompletedTask);
+
+ var userManager = new Mock<IUserManager>();
+ var host = new DeviceAccessHost(
+ userManager.Object,
+ deviceManager.Object,
+ sessionManager.Object,
+ NullLogger<DeviceAccessHost>.Instance);
+ await host.StartAsync(TestContext.Current.CancellationToken);
+
+ userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user));
+
+ await loggedOut.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ sessionManager.Verify(s => s.Logout(device), Times.Once);
+ }
+
+ private sealed class CapturingSynchronizationContext : SynchronizationContext
+ {
+ public List<Exception> Exceptions { get; } = new List<Exception>();
+
+ public override void Post(SendOrPostCallback d, object? state) => Run(d, state);
+
+ public override void Send(SendOrPostCallback d, object? state) => Run(d, state);
+
+ private void Run(SendOrPostCallback d, object? state)
+ {
+ try
+ {
+ d(state);
+ }
+ catch (Exception ex)
+ {
+ Exceptions.Add(ex);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
index c940f92109..e91ebdf1b6 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
@@ -17,6 +18,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.Users;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
@@ -120,6 +122,27 @@ public sealed class UserManagerUpdateUserTests : IDisposable
}
[Fact]
+ public async Task UpdatePolicyAsync_RaisesOnUserUpdated()
+ {
+ var user = await _userManager.CreateUserAsync("policyeventuser");
+
+ User? updated = null;
+ _userManager.OnUserUpdated += (_, e) => updated = e.Argument;
+
+ await _userManager.UpdatePolicyAsync(
+ user.Id,
+ new UserPolicy
+ {
+ EnableAllDevices = false,
+ AuthenticationProviderId = user.AuthenticationProviderId,
+ PasswordResetProviderId = user.PasswordResetProviderId
+ });
+
+ Assert.NotNull(updated);
+ Assert.Equal(user.Id, updated.Id);
+ }
+
+ [Fact]
public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges()
{
var user = await _userManager.CreateUserAsync("policyuser");