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/Dto/DtoServiceTests.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs123
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs78
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs142
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs174
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs72
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs77
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs3
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs117
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs33
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs137
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs30
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs95
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs141
14 files changed, 1223 insertions, 1 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index bdac59c013..679e6d17e3 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -154,7 +154,7 @@ public class DtoServiceTests
.Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user))
.Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) });
_libraryManagerMock
- .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<Guid?>()))
+ .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()))
.Returns(new Dictionary<Guid, int> { [season.Id] = childCount });
return (season, user);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
new file mode 100644
index 0000000000..cdb261de8d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class LibraryChangedNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<ILibraryManager> _libraryManager = new();
+ private readonly Mock<IServerConfigurationManager> _configurationManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+ private readonly Mock<IProviderManager> _providerManager = new();
+ private readonly ServerConfiguration _configuration = new();
+
+ private int _flushCount;
+
+ public LibraryChangedNotifierTests()
+ {
+ _configurationManager.SetupGet(e => e.Configuration).Returns(_configuration);
+
+ // Reading the session list is the first thing a flush does, so it stands in for "a batch was
+ // sent" without having to mock a whole user library behind it.
+ _sessionManager.SetupGet(e => e.Sessions)
+ .Returns(() =>
+ {
+ Interlocked.Increment(ref _flushCount);
+ return [];
+ });
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow()
+ {
+ // Long enough that only the size cap can close the batch.
+ _configuration.LibraryUpdateDuration = 3600;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++)
+ {
+ RaiseItemUpdated();
+ }
+
+ Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes items continuously. The window must run from the first change of a batch, or
+ // the batch never closes and holds every item it named alive for the length of the scan.
+ _configuration.LibraryUpdateDuration = 1;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseItemUpdated();
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private LibraryChangedNotifier CreateNotifier()
+ => new(
+ _libraryManager.Object,
+ _configurationManager.Object,
+ _sessionManager.Object,
+ _userManager.Object,
+ NullLogger<LibraryChangedNotifier>.Instance,
+ _providerManager.Object);
+
+ // A folder passes the notifier's item filter without needing any of BaseItem's static services.
+ private void RaiseItemUpdated()
+ => _libraryManager.Raise(
+ e => e.ItemUpdated += null,
+ _libraryManager.Object,
+ new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } });
+
+ private async Task<bool> WaitForFlushAsync(int expected)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout)
+ {
+ if (Volatile.Read(ref _flushCount) >= expected)
+ {
+ return true;
+ }
+
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ return false;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
new file mode 100644
index 0000000000..0274398f89
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Session;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class UserDataChangeNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<IUserDataManager> _userDataManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+
+ private int _flushCount;
+
+ public UserDataChangeNotifierTests()
+ {
+ _sessionManager
+ .Setup(e => e.SendMessageToUserSessions(
+ It.IsAny<System.Collections.Generic.List<Guid>>(),
+ SessionMessageType.UserDataChanged,
+ It.IsAny<Func<UserDataChangeInfo>>(),
+ It.IsAny<CancellationToken>()))
+ .Callback(() => Interlocked.Increment(ref _flushCount))
+ .Returns(Task.CompletedTask);
+ }
+
+ [Fact]
+ public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes user data continuously. The window must run from the first change of a batch,
+ // or the batch never closes and holds every item it named alive for the length of the scan.
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var userId = Guid.NewGuid();
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseUserDataSaved(userId);
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private UserDataChangeNotifier CreateNotifier()
+ => new(_userDataManager.Object, _sessionManager.Object, _userManager.Object);
+
+ // A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the
+ // notifier ignores outright.
+ private void RaiseUserDataSaved(Guid userId)
+ => _userDataManager.Raise(
+ e => e.UserDataSaved += null,
+ _userDataManager.Object,
+ new UserDataSaveEventArgs
+ {
+ UserId = userId,
+ SaveReason = UserDataSaveReason.UpdateUserRating,
+ Item = new Folder { Id = Guid.NewGuid() }
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
new file mode 100644
index 0000000000..0ca11eb58d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+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 <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a
+/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a
+/// linked folder are only reachable by following the link and then the ancestor chain.
+/// </summary>
+public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture
+{
+ private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
+ private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+
+ private readonly BaseItemRepository _repository;
+
+ private readonly Guid _library = Guid.NewGuid();
+ private readonly Guid _collection = Guid.NewGuid();
+ private readonly Guid _series = Guid.NewGuid();
+ private readonly Guid _season = Guid.NewGuid();
+ private readonly Guid _episode = Guid.NewGuid();
+
+ // A movie the collection links directly, so the direct-child case is covered alongside the nested one.
+ private readonly Guid _collectionMovie = Guid.NewGuid();
+
+ // In the same library but outside the collection, as the control the assertions are read against.
+ private readonly Guid _otherSeries = Guid.NewGuid();
+ private readonly Guid _otherEpisode = Guid.NewGuid();
+
+ public BaseItemRepositoryDescendantFilterTests()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ Seed(ctx);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void DescendantOfId_ReachesEpisodesOfALinkedSeries()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Episode]
+ });
+
+ Assert.Equal([_episode], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_ReturnsEveryLevelBelowTheCollection()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet();
+
+ Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order());
+ }
+
+ [Fact]
+ public void DescendantOfId_KeepsDirectlyLinkedChildren()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal([_collectionMovie], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() });
+
+ Assert.Empty(ids);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" });
+
+ // AncestorIds is a closure: production writes one row per ancestor, not just the parent.
+ AddAncestors(context, _series, _library);
+ AddAncestors(context, _season, _series, _library);
+ AddAncestors(context, _episode, _season, _series, _library);
+ AddAncestors(context, _collectionMovie, _library);
+ AddAncestors(context, _otherSeries, _library);
+ AddAncestors(context, _otherEpisode, _otherSeries, _library);
+
+ AddLink(context, _series, 0);
+ AddLink(context, _collectionMovie, 1);
+
+ context.SaveChanges();
+ }
+
+ private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
+ {
+ foreach (var ancestorId in ancestorIds)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = ancestorId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder)
+ {
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _collection,
+ ChildId = childId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = sortOrder
+ });
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
new file mode 100644
index 0000000000..91148501ce
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
@@ -0,0 +1,174 @@
+using System;
+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;
+
+public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly string _audioTypeName;
+ private readonly string _movieTypeName;
+
+ public BaseItemRepositoryItemValueTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _audioTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetQueryFiltersLegacy_GroupsAndFiltersItemValues()
+ {
+ var firstItem = CreateMovieEntity(Guid.NewGuid(), "First");
+ var secondItem = CreateMovieEntity(Guid.NewGuid(), "Second");
+ var excludedItem = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Excluded Audio",
+ MediaType = "Audio",
+ IsMovie = false,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var firstTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Alpha",
+ CleanValue = "alpha"
+ };
+ var duplicateTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "alpha",
+ CleanValue = "alpha"
+ };
+ var secondTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Beta",
+ CleanValue = "beta"
+ };
+ var genre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Genre Leak",
+ CleanValue = "genre leak"
+ };
+ var excludedTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Excluded Tag",
+ CleanValue = "excluded tag"
+ };
+ var excludedGenre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Excluded Genre",
+ CleanValue = "excluded genre"
+ };
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(firstItem, secondItem, excludedItem);
+ context.ItemValues.AddRange(firstTag, duplicateTag, secondTag, genre, excludedTag, excludedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(firstItem, firstTag),
+ CreateMap(firstItem, duplicateTag),
+ CreateMap(secondItem, secondTag),
+ CreateMap(firstItem, genre),
+ CreateMap(excludedItem, excludedTag),
+ CreateMap(excludedItem, excludedGenre));
+ context.SaveChanges();
+ }
+
+ var result = _repository.GetQueryFiltersLegacy(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal(["Alpha", "Beta"], result.Tags);
+ Assert.Equal(["Genre Leak"], result.Genres);
+ }
+
+ [Fact]
+ public void GetGenreNames_GroupsAndFiltersMappedItemValues()
+ {
+ var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
+ var audio = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Audio",
+ MediaType = "Audio",
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var movieGenre = CreateItemValue(ItemValueType.Genre, "Movie Genre", "movie genre");
+ var duplicateMovieGenre = CreateItemValue(ItemValueType.Genre, "movie genre", "movie genre");
+ var musicGenre = CreateItemValue(ItemValueType.Genre, "Music Genre", "music genre");
+ var orphanedGenre = CreateItemValue(ItemValueType.Genre, "Orphaned Genre", "orphaned genre");
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(movie, audio);
+ context.ItemValues.AddRange(movieGenre, duplicateMovieGenre, musicGenre, orphanedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, movieGenre),
+ CreateMap(movie, duplicateMovieGenre),
+ CreateMap(audio, musicGenre));
+ context.SaveChanges();
+ }
+
+ Assert.Equal(["Movie Genre"], _repository.GetGenreNames());
+ Assert.Equal(["Music Genre"], _repository.GetMusicGenreNames());
+ }
+
+ private BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ {
+ return new ItemValueMap
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+ }
+
+ private static ItemValue CreateItemValue(ItemValueType type, string value, string cleanValue)
+ {
+ return new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = value,
+ CleanValue = cleanValue
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
index 947cf54d85..fea743f08e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -198,6 +198,78 @@ public sealed class ItemCountServiceTests : IDisposable
Assert.Equal(2, result[seriesB]);
}
+ [Fact]
+ public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+
+ // The series holds the season, not the episodes: counting those here would double them up.
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+ var user = new User("count-test", "provider", "reset");
+
+ user.DisplayMissingEpisodes = true;
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+
+ // Nothing this user can open, so nothing to report.
+ user.DisplayMissingEpisodes = false;
+ Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_NoUser_CountsMissingEpisodes()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]);
+ }
+
+ private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes)
+ {
+ var seriesId = Guid.NewGuid();
+ var seasonId = Guid.NewGuid();
+
+ using var context = CreateDbContext();
+ context.BaseItems.Add(CreateItem(seriesId));
+ context.BaseItems.Add(CreateItem(seasonId, seriesId));
+
+ // Flat: the episodes sit in the series folder, so ParentId points at the series and only
+ // SeasonId ties them to the season they belong to.
+ for (var i = 0; i < 2; i++)
+ {
+ var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId);
+ episode.Type = "MediaBrowser.Controller.Entities.TV.Episode";
+ episode.IsFolder = false;
+ episode.IsVirtualItem = virtualEpisodes;
+ episode.SeasonId = seasonId;
+ context.BaseItems.Add(episode);
+ }
+
+ context.SaveChanges();
+
+ return (seriesId, seasonId);
+ }
+
private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
{
var user = new User("count-test", "provider", "reset");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
new file mode 100644
index 0000000000..7997c6d771
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistenceServiceSaveImagesTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ReplacesThePreviousImages()
+ {
+ var itemId = Guid.NewGuid();
+ Seed(itemId);
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken);
+ await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ var paths = context.BaseItemImageInfos
+ .Where(e => e.ItemId.Equals(itemId))
+ .Select(e => e.Path)
+ .ToList();
+
+ Assert.Equal(["/second.jpg"], paths);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp()
+ {
+ // A scan can delete the item between the refresh reading it and the images being written. That
+ // must not fail the whole refresh, and must not leave the images of an item that is gone.
+ var itemId = Guid.NewGuid();
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId)));
+ }
+
+ private static BaseItem CreateItem(Guid itemId, string imagePath)
+ => new Folder
+ {
+ Id = itemId,
+ ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }]
+ };
+
+ private void Seed(Guid itemId)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = itemId,
+ Type = "Folder",
+ IsFolder = true
+ });
+ context.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
index 87efa8fea5..cfc9c9496c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
@@ -58,6 +59,8 @@ public abstract class SqliteDbTestFixture : IDisposable
{
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
return factory.Object;
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
new file mode 100644
index 0000000000..30f7bed208
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library.Validators;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Tests for how the people validator decides which credits need a person item and which person items
+/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves
+/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id.
+/// </summary>
+public class PeopleValidatorPartitionTests
+{
+ // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and
+ // from nothing else. The property that matters is that it does not depend on the item's own name.
+ private static Guid PersonId(string creditName)
+ {
+#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
+ var hash = System.Security.Cryptography.MD5.HashData(
+ System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant()));
+#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms
+ return new Guid(hash);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing()
+ {
+ // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider
+ // that refreshed it. Nothing about the library changed, so nothing should be created or deleted.
+ var credits = new[] { "AURORA" };
+ var existing = new HashSet<Guid> { PersonId("AURORA") };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Theory]
+ // Every shape of rename seen in the wild on a real library.
+ [InlineData("AURORA")]
+ [InlineData("Amir AboulEla")]
+ [InlineData("Miguel Ángel Fuentes")]
+ [InlineData("a‐ha")]
+ [InlineData("윤현민")]
+ public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName)
+ {
+ var existing = new HashSet<Guid> { PersonId(creditName) };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated()
+ {
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["Wanted Person"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Equal(["Wanted Person"], newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead()
+ {
+ var orphan = PersonId("Nobody Credits Me");
+ var existing = new HashSet<Guid> { PersonId("Credited"), orphan };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Equal([orphan], deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem()
+ {
+ // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly
+ // one of them should create the item and neither should end up dead.
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["AURORA", "Aurora", "aurora"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Single(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther()
+ {
+ // The churn showed up as a run that never settled, so drive two rounds: whatever round one
+ // created must leave round two with nothing to do.
+ string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"];
+ var existing = new HashSet<Guid>();
+
+ var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+ Assert.Equal(3, firstNames.Count);
+ Assert.Empty(firstDead);
+
+ foreach (var created in firstNames)
+ {
+ existing.Add(PersonId(created));
+ }
+
+ var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(secondNames);
+ Assert.Empty(secondDead);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
index feb2d8a625..67d924d152 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -62,6 +62,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.Equal(expectedId, actualId);
}
+ [Theory]
+ [InlineData("/media/Show/Season 01 [anidbid=11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 01 [anidbid-11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 02 [anilistid=22222]", "AniList", "22222")]
+ [InlineData("/media/Show/Season 02 (anilistid=22222)", "AniList", "22222")]
+ [InlineData("/media/Show/Season 03 [anisearchid=33333]", "AniSearch", "33333")]
+ public void Resolve_SeasonFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId)
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(providerKey, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
[Fact]
public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll()
{
@@ -140,6 +170,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ Assert.False(season.TryGetProviderId("AniDB", out _));
+ Assert.False(season.TryGetProviderId("AniList", out _));
+ Assert.False(season.TryGetProviderId("AniSearch", out _));
}
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
index a5a67046d1..f803c69af2 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
@@ -1,6 +1,9 @@
using System;
+using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
@@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -108,4 +113,136 @@ public class SessionManagerTests
return data;
}
+
+ [Fact]
+ public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand(
+ attackerSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "test exploit!" },
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("controller", "default", "default");
+ attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true);
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var controllingSession = await LogSessionActivity(sessionManager, attacker);
+
+ await sessionManager.SendMessageCommand(
+ controllingSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "hello" },
+ CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ // Client name and device id are attacker controlled, so they must not identify a session on their own.
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.NotEqual(victimSession.Id, attackerSession.Id);
+ Assert.Equal(victim.Id, victimSession.UserId);
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser()
+ {
+ var attacker = new User("attacker", "default", "default");
+ var victim = new User("victim", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id));
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator()
+ {
+ var admin = new User("admin", "default", "default");
+ admin.SetPermission(PermissionKind.IsAdministrator, true);
+ var guest = new User("guest", "default", "default");
+ await using var sessionManager = CreateSessionManager(admin, guest);
+
+ var adminSession = await LogSessionActivity(sessionManager, admin);
+
+ sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id);
+
+ Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id));
+ }
+
+ [Fact]
+ public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id));
+ }
+
+ [Fact]
+ public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities()));
+ }
+
+ private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users)
+ {
+ var userManager = new Mock<IUserManager>();
+ foreach (var user in users)
+ {
+ userManager.Setup(i => i.GetUserById(user.Id)).Returns(user);
+ }
+
+ return new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ Mock.Of<IEventManager>(),
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILibraryManager>(),
+ userManager.Object,
+ Mock.Of<IMusicManager>(),
+ Mock.Of<IDtoService>(),
+ Mock.Of<IImageProcessor>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<IDeviceManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IHostApplicationLifetime>());
+ }
+
+ // All sessions are logged with the same client and device id on purpose, those values are taken
+ // from the request headers and are not bound to the access token of the calling user.
+ private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user)
+ => sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
index 32685556b2..05e8a40de1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -143,6 +143,36 @@ public class PlayQueueManagerTests
}
[Fact]
+ public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(1);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(2);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(5, queue.GetPlaylist().Count);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
{
var queue = CreateQueue(2);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
new file mode 100644
index 0000000000..b1221f6f71
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Threading;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class SyncPlayManagerTests
+{
+ [Fact]
+ public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ // A client that re-sends Join for the group it is already in must not be counted twice.
+ harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately()
+ {
+ var harness = new ManagerHarness();
+ 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);
+
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ private sealed class ManagerHarness
+ {
+ private readonly Mock<ISessionManager> _sessionManager = new();
+
+ public ManagerHarness()
+ {
+ var userManager = new Mock<IUserManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ User = new User("tester", "auth-provider", "pwdreset-provider");
+ userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User);
+
+ Manager = new SyncPlayManager(
+ NullLoggerFactory.Instance,
+ userManager.Object,
+ _sessionManager.Object,
+ libraryManager.Object);
+
+ Session = CreateSession("session-1");
+ }
+
+ public SyncPlayManager Manager { get; }
+
+ public User User { get; }
+
+ public SessionInfo Session { get; }
+
+ public SessionInfo CreateSession(string id)
+ {
+ return new SessionInfo(_sessionManager.Object, NullLogger.Instance)
+ {
+ Id = id,
+ UserId = User.Id,
+ UserName = User.Username
+ };
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
new file mode 100644
index 0000000000..0cccd5d4ca
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+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.GroupStates;
+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;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class WaitingGroupStateTests
+{
+ [Fact]
+ public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ // Both members report a ping well under the default, so the floor is what decides the delay.
+ group.UpdatePing(harness.First, 10);
+ group.UpdatePing(harness.Second, 10);
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, false);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ // DefaultPing is expressed in milliseconds, so the floor must be converted before being
+ // compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms)
+ // and never applies.
+ var scheduledDelay = group.LastActivity - before;
+ Assert.True(
+ scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing),
+ $"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms");
+ }
+
+ [Theory]
+ [InlineData(4_000_000_000L)]
+ [InlineData(1_000_000_000_000_000L)]
+ [InlineData(long.MaxValue)]
+ [InlineData(-1L)]
+ public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing)
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.UpdatePing(harness.First, reportedPing);
+
+ Assert.InRange(group.GetHighestPing(), 0, group.MaxPing);
+
+ // The reported ping is scaled into the group's resume point, so an unclamped value either
+ // pushes playback months out or overflows the arithmetic outright.
+ var state = new PlayingGroupState(NullLoggerFactory.Instance);
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new UnpauseGroupRequest(),
+ group,
+ GroupStateType.Paused,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1));
+ }
+
+ private sealed class GroupHarness
+ {
+ public GroupHarness()
+ {
+ var userManager = new Mock<IUserManager>();
+ var sessionManager = new Mock<ISessionManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ var 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);
+
+ 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)
+ {
+ Id = "second",
+ UserId = user.Id,
+ UserName = "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);
+ PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId();
+ }
+
+ public SyncPlayGroup Group { get; }
+
+ public SessionInfo First { get; }
+
+ public SessionInfo Second { get; }
+
+ public Guid PlaylistItemId { get; }
+ }
+}