aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs60
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs106
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs97
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs84
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs100
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs130
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs137
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs43
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml10
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs5
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs40
-rw-r--r--tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs143
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs41
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs155
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs113
-rw-r--r--tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs110
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs13
-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/Item/BaseItemMapperUserDataTests.cs79
-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/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/MovieSimilarItemsProviderTests.cs104
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs126
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs79
-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
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs36
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs26
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs202
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs216
50 files changed, 4590 insertions, 32 deletions
diff --git a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs
new file mode 100644
index 0000000000..272c434fe9
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+[Collection("LibraryManagerTests")]
+public class AggregateFolderTests
+{
+ [Fact]
+ public void Children_ClearedAfterALibraryWasAdded_ListsTheNewLibrary()
+ {
+ var existing = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" };
+ var added = new Folder { Id = Guid.NewGuid(), Path = "/libraries/collections" };
+
+ // What the repository holds grows once the new library has been resolved and stored.
+ var stored = new List<BaseItem> { existing };
+
+ var itemRepository = new Mock<IItemRepository>();
+ itemRepository.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(() => stored.ToList());
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetItemById(It.IsAny<Guid>()))
+ .Returns((Guid id) => stored.Find(i => i.Id.Equals(id)));
+
+ BaseItem.ItemRepository = itemRepository.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" };
+
+ Assert.Equal([existing.Id], root.Children.Select(i => i.Id));
+
+ stored.Add(added);
+ root.Children = null;
+
+ // Null-forgiving: the setter takes null to mean "drop the cache", the getter reloads.
+ Assert.Equal([existing.Id, added.Id], root.Children!.Select(i => i.Id));
+ }
+
+ [Fact]
+ public void Children_AssignedASet_KeepsThatSet()
+ {
+ var itemRepository = new Mock<IItemRepository>(MockBehavior.Strict);
+ BaseItem.ItemRepository = itemRepository.Object;
+
+ var assigned = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" };
+ var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" };
+
+ root.Children = [assigned];
+
+ // Never goes to the repository, so the strict mock stays unused.
+ Assert.Equal([assigned.Id], root.Children.Select(i => i.Id));
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 86bac4256a..e072bccb82 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -26,8 +27,63 @@ using Xunit;
namespace Jellyfin.Controller.Tests.Entities;
+[Collection("LibraryManagerTests")]
public class BaseItemTests
{
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ public async Task ValidateChildren_FailedEnumeration_DoesNotReconcileOrDeleteChildren(bool failAfterFirstChild, bool accessDenied)
+ {
+ var previousLibrary = BaseItem.LibraryManager;
+ var previousRepository = BaseItem.ItemRepository;
+ var previousLogger = BaseItem.Logger;
+ var library = new Mock<ILibraryManager>(MockBehavior.Strict);
+ var repository = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>(MockBehavior.Strict);
+ var directory = new Mock<IDirectoryService>();
+ directory.Setup(d => d.IsAccessible(It.IsAny<string>())).Returns(true);
+ try
+ {
+ BaseItem.LibraryManager = library.Object;
+ BaseItem.ItemRepository = repository.Object;
+ BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance;
+ var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied)
+ {
+ Id = Guid.NewGuid(),
+ Path = "/media/review-folder"
+ };
+ await folder.ValidateChildren(new Progress<double>(), new MetadataRefreshOptions(directory.Object), recursive: false, cancellationToken: TestContext.Current.CancellationToken).ConfigureAwait(true);
+ Assert.True(folder.EnumerationAttempted);
+ repository.VerifyNoOtherCalls();
+ library.VerifyNoOtherCalls();
+ }
+ finally
+ {
+ BaseItem.LibraryManager = previousLibrary;
+ BaseItem.ItemRepository = previousRepository;
+ BaseItem.Logger = previousLogger;
+ }
+ }
+
+ [Fact]
+ public void SetPrimaryVersionId_Null_RestoresTheItemsOwnPresentationKey()
+ {
+ var primaryId = Guid.NewGuid();
+ var video = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv" };
+
+ // While it is a version, it presents as the primary so lists collapse the two together.
+ video.SetPrimaryVersionId(primaryId);
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey);
+
+ // Promoting it back has to restore its own key, or it keeps collapsing onto - and staying
+ // hidden behind - a primary it no longer belongs to.
+ video.SetPrimaryVersionId(null);
+ Assert.Null(video.PrimaryVersionId);
+ Assert.Equal(video.Id.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey);
+ }
+
[Fact]
public void GetItemByNameFolderName_ShortName_IsKeptAsIs()
{
@@ -185,6 +241,17 @@ public class BaseItemTests
"Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
"[Final Cut] [1080p HEVC AAC]",
"[EE by ADM] [480p HEVC AAC]")]
+ // Numeric version labels: the dot between the digits is a decimal point, not a delimiter, so the
+ // prefix retreats past it to the '-' instead of leaving "0" / "11".
+ [InlineData(
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11",
+ "1.0",
+ "1.11")]
+ // Numeric labels with no structural delimiter at all fall back to the space boundary.
+ [InlineData("Movie (2007) 1.0", "Movie (2007) 1.11", "1.0", "1.11")]
+ // A dot followed by a non-digit is still a delimiter, even after a digit.
+ [InlineData("Movie - Part 1.HDR", "Movie - Part 1.SDR", "HDR", "SDR")]
public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
{
var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
@@ -216,6 +283,24 @@ public class BaseItemTests
}
[Fact]
+ public void GetCommonVersionPrefix_NumericLabels_KeepsWholeNumber()
+ {
+ // Three versions labelled "1.0", "1.01" and "1.11": the common prefix stops inside the version
+ // number, so it must retreat past the decimal point to the '-' delimiter.
+ string[] fileNames =
+ [
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.01",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11"
+ ];
+
+ var prefix = BaseItem.GetCommonVersionPrefix(fileNames);
+
+ Assert.Equal("Evangelion 1.0 You Are (Not) Alone (2007) -", prefix);
+ Assert.Equal(["1.0", "1.01", "1.11"], fileNames.Select(n => n[prefix.Length..].TrimStart(' ')));
+ }
+
+ [Fact]
public void GetAlternateVersion_ReturnsMatchingLocalVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
@@ -644,4 +729,25 @@ public class BaseItemTests
Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids);
}
+
+ private sealed class FailingEnumerationFolder(bool failAfterFirstChild, bool accessDenied) : Folder
+ {
+ public bool EnumerationAttempted { get; private set; }
+
+ protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService)
+ {
+ EnumerationAttempted = true;
+ if (failAfterFirstChild)
+ {
+ yield return new Movie { Id = Guid.NewGuid(), Path = "/media/review-folder/movie.mkv" };
+ }
+
+ if (accessDenied)
+ {
+ throw new System.Security.SecurityException("Simulated access failure");
+ }
+
+ throw new IOException("Simulated directory read failure");
+ }
+ }
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs
new file mode 100644
index 0000000000..705238317a
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+/// <summary>
+/// Covers <see cref="Folder.ReleaseCachedChildren"/>, which a recursive scan calls as it unwinds so
+/// the folders it walked do not keep the whole item graph of the library alive behind it.
+/// </summary>
+public class FolderChildCacheTests
+{
+ [Fact]
+ public void ReleaseCachedChildren_MakesTheNextAccessReload()
+ {
+ var folder = new TrackingFolder();
+
+ Assert.Empty(folder.Children);
+ Assert.Equal(1, folder.LoadCount);
+
+ // Second access is served from the cache on the instance.
+ Assert.Empty(folder.Children);
+ Assert.Equal(1, folder.LoadCount);
+
+ folder.ReleaseCachedChildren();
+
+ Assert.Empty(folder.Children);
+ Assert.Equal(2, folder.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_ReachesEveryLevelBelow()
+ {
+ var leaf = new TrackingFolder();
+ var middle = new TrackingFolder { Source = [leaf] };
+ var root = new TrackingFolder { Source = [middle] };
+
+ // Walk the whole tree, as a recursive scan does, so every level holds its children.
+ Assert.Single(root.Children);
+ Assert.Single(middle.Children);
+ Assert.Empty(leaf.Children);
+ Assert.Equal(1, root.LoadCount);
+ Assert.Equal(1, middle.LoadCount);
+ Assert.Equal(1, leaf.LoadCount);
+
+ root.ReleaseCachedChildren();
+
+ Assert.Single(root.Children);
+ Assert.Single(middle.Children);
+ Assert.Empty(leaf.Children);
+ Assert.Equal(2, root.LoadCount);
+ Assert.Equal(2, middle.LoadCount);
+ Assert.Equal(2, leaf.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_LoadsNothingThatIsNotAlreadyHeld()
+ {
+ var leaf = new TrackingFolder();
+ var root = new TrackingFolder { Source = [leaf] };
+
+ root.ReleaseCachedChildren();
+
+ Assert.Equal(0, root.LoadCount);
+ Assert.Equal(0, leaf.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_TerminatesOnACycle()
+ {
+ var first = new TrackingFolder();
+ var second = new TrackingFolder { Source = [first] };
+ first.Source = [second];
+
+ Assert.Single(first.Children);
+ Assert.Single(second.Children);
+
+ // Clearing before descending is what stops this from recursing forever.
+ first.ReleaseCachedChildren();
+
+ Assert.Equal(1, first.LoadCount);
+ Assert.Equal(1, second.LoadCount);
+ }
+
+ private sealed class TrackingFolder : Folder
+ {
+ public int LoadCount { get; private set; }
+
+ public IReadOnlyList<BaseItem> Source { get; set; } = [];
+
+ protected override IReadOnlyList<BaseItem> LoadChildren()
+ {
+ LoadCount++;
+ return Source;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
new file mode 100644
index 0000000000..70da5eafe5
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Linq;
+using Jellyfin.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Querying;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+public class PlaylistTests
+{
+ [Fact]
+ public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden()
+ {
+ // The SQL parental filter hides a container whose every member is blocked, so a listing
+ // built in memory has to reach the same answer.
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ SetupLibrary(blocked);
+
+ Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser()));
+ }
+
+ [Fact]
+ public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible()
+ {
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" };
+ SetupLibrary(blocked, allowed);
+
+ Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser()));
+ }
+
+ [Fact]
+ public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved()
+ {
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ var libraryManager = SetupLibrary(blocked);
+ var user = new User("user", "auth-provider", "reset-provider");
+
+ Assert.True(BuildPlaylist(blocked).IsVisible(user));
+
+ // Resolving a playlist's items is a query per playlist; nothing may run it for a user no
+ // rating keeps anything from.
+ libraryManager.Verify(x => x.GetItemList(It.IsAny<InternalItemsQuery>()), Times.Never);
+ }
+
+ private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(items);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return libraryManager;
+ }
+
+ private static Playlist BuildPlaylist(params BaseItem[] items)
+ {
+ // An empty path keeps the playlist out of the shared-playlist branch.
+ return new Playlist
+ {
+ Id = Guid.NewGuid(),
+ Name = "Playlist",
+ LinkedChildren = items.Select(LinkedChild.Create).ToArray()
+ };
+ }
+
+ private static User BuildRestrictedUser()
+ {
+ var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 };
+ user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie });
+
+ return user;
+ }
+}
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs
new file mode 100644
index 0000000000..18518df1c2
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs
@@ -0,0 +1,100 @@
+using SkiaSharp;
+using Xunit;
+
+namespace Jellyfin.Drawing.Skia.Tests;
+
+/// <summary>
+/// Covers what <see cref="SkiaEncoder.ResizeImage"/> does either side of a resize: at matching
+/// dimensions it must not touch the image at all, and sharpening belongs to downscales only.
+/// </summary>
+public class SkiaEncoderResizeTests
+{
+ private static SKBitmap CreateEdgeBitmap(int width, int height)
+ {
+ var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul));
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(new SKColor(40, 60, 80));
+ using var paint = new SKPaint { Color = new SKColor(220, 210, 200) };
+ canvas.DrawRect(SKRect.Create(0, 0, width / 2f, height), paint);
+
+ return bitmap;
+ }
+
+ private static SKImageInfo InfoFor(SKBitmap source, int width, int height)
+ => new SKImageInfo(width, height, source.ColorType, source.AlphaType, source.ColorSpace);
+
+ /// <summary>
+ /// Draws without sharpening, which is what the resize is expected to reduce to when it is not
+ /// downscaling.
+ /// </summary>
+ private static SKBitmap DrawOnly(SKBitmap source, SKImageInfo targetInfo, SKSamplingOptions sampling)
+ {
+ var target = new SKBitmap(targetInfo);
+ using var canvas = new SKCanvas(target);
+ using var paint = new SKPaint();
+ canvas.DrawBitmap(
+ source,
+ SKRect.Create(0, 0, source.Width, source.Height),
+ SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
+ sampling,
+ paint);
+
+ return target;
+ }
+
+ private static void AssertSamePixels(SKBitmap expected, SKBitmap actual)
+ {
+ Assert.Equal(expected.Width, actual.Width);
+ Assert.Equal(expected.Height, actual.Height);
+
+ for (var y = 0; y < expected.Height; y++)
+ {
+ for (var x = 0; x < expected.Width; x++)
+ {
+ Assert.Equal(expected.GetPixel(x, y), actual.GetPixel(x, y));
+ }
+ }
+ }
+
+ [Fact]
+ public void ResizeImage_MatchingDimensions_ReturnsTheImageUntouched()
+ {
+ using var source = CreateEdgeBitmap(16, 16);
+
+ using var result = SkiaEncoder.ResizeImage(source, InfoFor(source, 16, 16));
+ using var resultBitmap = SKBitmap.FromImage(result);
+
+ // Unsharpened, so the edge is still exactly where it was.
+ AssertSamePixels(source, resultBitmap);
+ }
+
+ [Fact]
+ public void ResizeImage_Upscale_DoesNotSharpen()
+ {
+ using var source = CreateEdgeBitmap(8, 8);
+ var targetInfo = InfoFor(source, 24, 24);
+
+ using var result = SkiaEncoder.ResizeImage(source, targetInfo);
+ using var resultBitmap = SKBitmap.FromImage(result);
+ using var expected = DrawOnly(source, targetInfo, SkiaEncoder.UpscaleSamplingOptions);
+
+ AssertSamePixels(expected, resultBitmap);
+ }
+
+ [Fact]
+ public void ResizeImage_Downscale_StillSharpens()
+ {
+ using var source = CreateEdgeBitmap(32, 32);
+ var targetInfo = InfoFor(source, 16, 16);
+
+ using var result = SkiaEncoder.ResizeImage(source, targetInfo);
+ using var resultBitmap = SKBitmap.FromImage(result);
+ using var unsharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions);
+ using var sharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions);
+ SkiaEncoder.SharpenInPlace(sharpened);
+
+ AssertSamePixels(sharpened, resultBitmap);
+ // Guards the test itself: the edge has to be something sharpening actually changes.
+ Assert.NotEqual(unsharpened.GetPixel(8, 8), sharpened.GetPixel(8, 8));
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
new file mode 100644
index 0000000000..04c7b05c3d
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
@@ -0,0 +1,130 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+using SchedulesDirectProvider = Jellyfin.LiveTv.Listings.SchedulesDirect;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public class SchedulesDirectLineupTests
+{
+ private const string InvalidUserResponse = "{\"response\":\"INVALID_USER\",\"code\":4003,\"message\":\"Invalid user.\",\"serverID\":\"AWS-SD-web.1\"}";
+
+ private static readonly ListingsProviderInfo _info = new() { Username = "user", Password = "password" };
+
+ [Fact]
+ public async Task GetLineups_ValidCredentials_ReturnsLineups()
+ {
+ var tokenResponse = await CreateSuccessfulLogin();
+ using var provider = CreateProvider(tokenResponse, await GetHeadendsResponse());
+
+ var lineups = await provider.GetLineups(_info, "USA", "90210");
+
+ Assert.NotEmpty(lineups);
+ Assert.Contains(lineups, i => string.Equals(i.Id, "USA-OTA-90210", StringComparison.Ordinal));
+ Assert.Contains(lineups, i => string.Equals(i.Name, "Antenna", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task GetLineups_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ // An empty lineup list is indistinguishable from "no lineups for this location", so a
+ // failed login has to surface as an error instead.
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ [Fact]
+ public async Task Validate_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.Validate(_info, true, false));
+ }
+
+ [Fact]
+ public async Task Validate_AfterAccountError_RecoversWithoutRestart()
+ {
+ var login = CreateFailedLogin();
+ using var provider = CreateProvider(login, await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+
+ // The account error disables Schedules Direct; saving the provider again is the user
+ // correcting their credentials, and that has to recover without a server restart.
+ login.Status = HttpStatusCode.OK;
+ login.Body = await GetTokenResponse();
+
+ await provider.Validate(_info, true, false);
+
+ Assert.NotEmpty(await provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ private static async Task<Response> CreateSuccessfulLogin()
+ => new() { Status = HttpStatusCode.OK, Body = await GetTokenResponse() };
+
+ private static Response CreateFailedLogin()
+ => new() { Status = HttpStatusCode.BadRequest, Body = InvalidUserResponse };
+
+ private static Task<string> GetTokenResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/token_live_response.json", TestContext.Current.CancellationToken);
+
+ private static Task<string> GetHeadendsResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/headends_response.json", TestContext.Current.CancellationToken);
+
+ private static SchedulesDirectProvider CreateProvider(Response login, string headendsResponse)
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ var path = m.RequestUri!.AbsolutePath;
+ if (path.EndsWith("/token", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(login.Status)
+ {
+ Content = new StringContent(login.Body)
+ });
+ }
+
+ if (path.EndsWith("/headends", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(headendsResponse)
+ });
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(Path.GetTempPath());
+
+ return new SchedulesDirectProvider(
+ NullLogger<SchedulesDirectProvider>.Instance,
+ httpClientFactory.Object,
+ appPaths.Object);
+ }
+
+ private sealed class Response
+ {
+ public HttpStatusCode Status { get; set; }
+
+ public string Body { get; set; } = string.Empty;
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
new file mode 100644
index 0000000000..1d96c5a958
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public sealed class XmlTvListingsProviderCacheTests : IDisposable
+{
+ private const string ChannelId = "3297";
+
+ private readonly string _cachePath = Path.Combine(Path.GetTempPath(), "jellyfin-xmltv-tests-" + Guid.NewGuid().ToString("N"));
+
+ private readonly ListingsProviderInfo _info = new()
+ {
+ Id = "cachetests",
+ Path = "https://example.com/notitle.xml"
+ };
+
+ private bool _downloadsFail;
+ private Exception? _downloadException;
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_cachePath))
+ {
+ Directory.Delete(_cachePath, true);
+ }
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadFailsAfterASuccess_KeepsUsingTheCachedListings()
+ {
+ var provider = CreateProvider();
+
+ Assert.NotEmpty(await GetPrograms(provider));
+
+ // Age the cached copy out, so the next call goes back to the (now broken) source.
+ var cacheFile = Path.Combine(_cachePath, "xmltv", _info.Id + ".xml");
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddDays(-1));
+ _downloadsFail = true;
+
+ // Losing the listings entirely because a single download failed empties the whole guide.
+ Assert.NotEmpty(await GetPrograms(provider));
+ Assert.True(File.Exists(cacheFile));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadTimesOut_DoesNotSurfaceAsCancellation()
+ {
+ var provider = CreateProvider();
+
+ // This is how HttpClient reports its own timeout. Left as an OperationCanceledException it
+ // aborts the guide refresh for every channel and provider instead of only this one.
+ _downloadsFail = true;
+ _downloadException = new TaskCanceledException("timeout", new TimeoutException());
+
+ await Assert.ThrowsAsync<TimeoutException>(() => GetPrograms(provider));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_ProviderSavedAfterAFailure_DownloadsAgain()
+ {
+ var provider = CreateProvider();
+
+ _downloadsFail = true;
+ await Assert.ThrowsAnyAsync<Exception>(() => GetPrograms(provider));
+
+ // Without clearing the backoff the guide stays empty for an hour, even though saving the
+ // provider deletes the cached file and is the user asking for another attempt.
+ _downloadsFail = false;
+ await provider.Validate(_info, true, true);
+
+ Assert.NotEmpty(await GetPrograms(provider));
+ }
+
+ private async Task<ProgramInfo[]> GetPrograms(XmlTvListingsProvider provider)
+ {
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await provider.GetProgramsAsync(_info, ChannelId, startDate, startDate.AddDays(1), CancellationToken.None);
+
+ return programs.ToArray();
+ }
+
+ private XmlTvListingsProvider CreateProvider()
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ if (_downloadException is not null)
+ {
+ return Task.FromException<HttpResponseMessage>(_downloadException);
+ }
+
+ if (_downloadsFail)
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1])))
+ });
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(_cachePath);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(x => x.ApplicationPaths).Returns(appPaths.Object);
+ config.SetupGet(x => x.Configuration).Returns(new ServerConfiguration());
+
+ return new XmlTvListingsProvider(
+ config.Object,
+ httpClientFactory.Object,
+ NullLogger<XmlTvListingsProvider>.Instance);
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index f698edc637..a8ebc8c9b9 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -90,6 +90,49 @@ public class XmlTvListingsProviderTests
AssertXmlTvEtag(program.Etag);
}
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetProgramsAsync_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-programs",
+ Path = path
+ };
+
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None);
+ var program = Assert.Single(programs.ToList());
+ Assert.Equal("Programme Without Icon Or Rating", program.Name);
+ Assert.False(program.HasImage);
+ Assert.Null(program.ImageUrl);
+ Assert.Null(program.ThumbImageUrl);
+ Assert.Null(program.BackdropImageUrl);
+ Assert.Null(program.OfficialRating);
+ Assert.Null(program.CommunityRating);
+ AssertXmlTvEtag(program.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetChannels_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-channels",
+ Path = path
+ };
+
+ var channels = await _xmlTvListingsProvider.GetChannels(info, CancellationToken.None);
+ var channel = Assert.Single(channels);
+ Assert.Equal("3297", channel.Id);
+ Assert.Equal("Channel Without Icon", channel.Name);
+ Assert.Equal("3297", channel.Number);
+ Assert.Null(channel.ImageUrl);
+ }
+
[Fact]
public async Task GetProgramsAsync_Etag_SameContentIsStable()
{
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
new file mode 100644
index 0000000000..e82d00c259
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
@@ -0,0 +1,10 @@
+<tv date="20221104">
+ <channel id="3297">
+ <display-name>Channel Without Icon</display-name>
+ </channel>
+ <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000">
+ <title lang="en">Programme Without Icon Or Rating</title>
+ <desc lang="en">A programme that only uses the required XMLTV elements.</desc>
+ <category lang="en">sports</category>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
index b81b7934cd..023c6cb2fa 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
@@ -20,6 +20,11 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("/some/path/The Show s02e10 720p hdtv", "The Show")]
[InlineData("/some/path/The Show s02e10 the episode 720p hdtv", "The Show")]
[InlineData("/some/path/1923 (2022)", "1923")]
+ // A dotted acronym keeps its dots when it follows words, whether they are space or dot separated
+ [InlineData("/some/path/Marvel's Agents of S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")]
+ [InlineData("Marvel's.Agents.of.S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")]
+ [InlineData("The.Show.S.H.O.W", "The Show S.H.O.W")]
+ [InlineData("/some/path/Dawson's Creek", "Dawson's Creek")]
public void SeriesResolverResolveTest(string path, string name)
{
var res = SeriesResolver.Resolve(_namingOptions, path);
diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
index b29c64f50d..62c1cf25d7 100644
--- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
@@ -1130,5 +1130,45 @@ namespace Jellyfin.Naming.Tests.Video
Assert.Equal(2, result[0].Files.Count);
Assert.Single(result[0].AlternateVersions);
}
+
+ [Fact]
+ public void TestMultiVersionEpisodeAbsoluteNumberingWithNumberInSeriesTitle()
+ {
+ // Every one of these parses as episode 2, because the expressions read the "2" of the
+ // series title as an absolute episode number. They are distinct episodes all the same.
+ var files = new[]
+ {
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 01 - The Memory of a Summer (b6f40849).mkv",
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 02 - Heart Pain Killer (d8c0896c).mkv",
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 03 - Translucent Chord (4ecce3dd).mkv",
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 04 - The Mysterious Lady (837a1909).mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(4, result.Count);
+ Assert.All(result, r => Assert.Empty(r.AlternateVersions));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeAbsoluteNumberingDontCollapse()
+ {
+ // Plain absolute numbering: no season number is available, so the files stay separate.
+ var files = new[]
+ {
+ "/anime/Bleach/Bleach - 001 - The Day I Became a Shinigami.mkv",
+ "/anime/Bleach/Bleach - 002 - The Shinigami's Work.mkv",
+ "/anime/Bleach/Bleach - 003 - The Older Brother's Wish.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(3, result.Count);
+ Assert.All(result, r => Assert.Empty(r.AlternateVersions));
+ }
}
}
diff --git a/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs
new file mode 100644
index 0000000000..f7f29d9768
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs
@@ -0,0 +1,143 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Books.ComicBookInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Books;
+
+public sealed class ComicBookInfoProviderTests : IDisposable
+{
+ private const string ValidComment = """
+ {"appID":"test","ComicBookInfo/1.0":{"series":"Jungle Juice","title":"Episode 36","issue":175}}
+ """;
+
+ private readonly string _directory;
+
+ public ComicBookInfoProviderTests()
+ {
+ _directory = Path.Combine(Path.GetTempPath(), "jf-cbz-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_directory);
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(_directory, true);
+ }
+
+ [Theory]
+ [InlineData(null)] // archive written without ever touching Comment
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task ReadMetadata_EmptyArchiveComment_SkipsWithoutDeserializing(string? comment)
+ {
+ var logger = new Mock<ILogger<ComicBookInfoProvider>>();
+ var path = CreateArchive(comment);
+ var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object);
+
+ var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None);
+
+ Assert.False(result.HasMetadata);
+ VerifyLogged(logger, LogLevel.Debug, "missing ComicBookInfo in archive comment");
+ VerifyNothingLoggedAbove(logger, LogLevel.Debug);
+ }
+
+ [Fact]
+ public async Task ReadMetadata_ArchiveCommentIsNotComicBookInfo_SkipsWithoutError()
+ {
+ var logger = new Mock<ILogger<ComicBookInfoProvider>>();
+ var path = CreateArchive("Created by some packer");
+ var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object);
+
+ var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None);
+
+ Assert.False(result.HasMetadata);
+ VerifyLogged(logger, LogLevel.Debug, "archive comment is not valid ComicBookInfo metadata");
+ VerifyNothingLoggedAbove(logger, LogLevel.Debug);
+ }
+
+ [Fact]
+ public async Task ReadMetadata_ValidComicBookInfoComment_ReturnsMetadata()
+ {
+ var logger = new Mock<ILogger<ComicBookInfoProvider>>();
+ var path = CreateArchive(ValidComment);
+ var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object);
+
+ var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None);
+
+ Assert.True(result.HasMetadata);
+ Assert.NotNull(result.Item);
+ Assert.Equal("Episode 36", result.Item.Name);
+ Assert.Equal("Jungle Juice", result.Item.SeriesName);
+ Assert.Equal(175, result.Item.IndexNumber);
+ VerifyNothingLoggedAbove(logger, LogLevel.Debug);
+ }
+
+ private static void VerifyLogged(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level, string message)
+ {
+ logger.Verify(
+ x => x.Log(
+ level,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains(message, StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ private static void VerifyNothingLoggedAbove(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level)
+ {
+ // a comment that holds no ComicBookInfo is normal, so it must not reach the log of a default install
+ logger.Verify(
+ x => x.Log(
+ It.Is<LogLevel>(actual => actual > level),
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ private static IFileSystem CreateFileSystem(string path)
+ {
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(x => x.GetFileSystemInfo(path))
+ .Returns(new FileSystemMetadata
+ {
+ Exists = true,
+ FullName = path,
+ Name = Path.GetFileName(path),
+ Extension = ".cbz",
+ IsDirectory = false
+ });
+
+ return fileSystem.Object;
+ }
+
+ private string CreateArchive(string? comment)
+ {
+ var path = Path.Combine(_directory, Guid.NewGuid().ToString("N") + ".cbz");
+
+ using (var stream = File.Create(path))
+ using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
+ {
+ if (comment is not null)
+ {
+ archive.Comment = comment;
+ }
+
+ var entry = archive.CreateEntry("ComicInfo.xml");
+ using var writer = new StreamWriter(entry.Open());
+ writer.Write("<ComicInfo />");
+ }
+
+ return path;
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
index 1ec859223e..459973acba 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
@@ -557,6 +557,47 @@ namespace Jellyfin.Providers.Tests.Manager
Assert.Equal(expectedToUpdate, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
}
+ [Fact]
+ public async Task RefreshImages_ProviderDynamicThrows_CountsAFailure()
+ {
+ var item = GetItemWithImages(ImageType.Primary, 0, false);
+ var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1);
+
+ var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict);
+ dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider");
+ dynamicProvider.Setup(rp => rp.GetSupportedImages(item))
+ .Returns(new[] { ImageType.Primary });
+ dynamicProvider.Setup(rp => rp.GetImage(item, ImageType.Primary, It.IsAny<CancellationToken>()))
+ .ThrowsAsync(new InvalidOperationException("provider is broken"));
+
+ var itemImageProvider = GetItemImageProvider(null, new Mock<IFileSystem>());
+ var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None);
+
+ // Without this the caller stamps DateLastRefreshed and never asks this provider again.
+ Assert.Equal(1, result.Failures);
+ }
+
+ [Fact]
+ public async Task RefreshImages_ProviderRemoteThrows_CountsAFailure()
+ {
+ var item = GetItemWithImages(ImageType.Primary, 0, false);
+ var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1);
+
+ var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict);
+ remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider");
+ remoteProvider.Setup(rp => rp.GetSupportedImages(item))
+ .Returns(new[] { ImageType.Primary });
+
+ var providerManager = new Mock<IProviderManager>(MockBehavior.Strict);
+ providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>()))
+ .ThrowsAsync(new HttpRequestException("unreachable"));
+
+ var itemImageProvider = GetItemImageProvider(providerManager.Object, new Mock<IFileSystem>());
+ var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None);
+
+ Assert.Equal(1, result.Failures);
+ }
+
private static ItemImageProvider GetItemImageProvider(IProviderManager? providerManager, Mock<IFileSystem>? mockFileSystem)
{
// strict to ensure this isn't accidentally used where a prepared mock is intended
diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
index 3b4d6fc9bb..acf8de4366 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
@@ -287,6 +287,161 @@ namespace Jellyfin.Providers.Tests.Manager
}
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task RefreshMetadata_CustomProviderThrew_LeavesRefreshDateAlone(bool providerThrows)
+ {
+ var item = new TestItem
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Item",
+ PreferredMetadataLanguage = "en",
+ PreferredMetadataCountryCode = "US",
+ DateLastRefreshed = DateTime.UtcNow.AddDays(-60),
+ DateLastSaved = DateTime.UtcNow.AddDays(-60)
+ };
+ item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
+
+ var stampBefore = item.DateLastRefreshed;
+
+ // Stands in for the probe provider, whose only other change monitor is the file's
+ // modification time: if a throw is stamped as a completed refresh the item is never revisited.
+ var provider = new Mock<ICustomMetadataProvider<TestItem>>(MockBehavior.Loose);
+ provider.Setup(p => p.Name).Returns("Throwing Provider");
+ provider.Setup(p => p.FetchAsync(It.IsAny<TestItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>()))
+ .Returns(providerThrows
+ ? Task.FromException<ItemUpdateType>(new InvalidOperationException("probe failed"))
+ : Task.FromResult(ItemUpdateType.None));
+
+ var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose);
+ libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+
+ var providerManager = new Mock<IProviderManager>(MockBehavior.Loose);
+ providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>()))
+ .Returns(Array.Empty<IImageProvider>());
+ providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(new[] { (IMetadataProvider<TestItem>)provider.Object });
+ providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(Array.Empty<IMetadataSaver>());
+
+ var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose);
+ itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true);
+
+ var service = new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object);
+
+ await service.RefreshMetadata(
+ item,
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh
+ },
+ CancellationToken.None).ConfigureAwait(true);
+
+ Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task RefreshMetadata_ImageProviderThrew_LeavesRefreshDateAlone(bool providerThrows)
+ {
+ var item = NewStampedTestItem();
+ var stampBefore = item.DateLastRefreshed;
+
+ var imageProvider = new Mock<IDynamicImageProvider>(MockBehavior.Loose);
+ imageProvider.Setup(p => p.Name).Returns("Throwing Image Provider");
+ imageProvider.Setup(p => p.GetSupportedImages(It.IsAny<BaseItem>())).Returns(new[] { ImageType.Primary });
+ imageProvider.Setup(p => p.GetImage(It.IsAny<BaseItem>(), ImageType.Primary, It.IsAny<CancellationToken>()))
+ .Returns(providerThrows
+ ? Task.FromException<DynamicImageResponse>(new InvalidOperationException("image fetch failed"))
+ : Task.FromResult(new DynamicImageResponse { HasImage = false }));
+
+ var providerManager = NewProviderManager(imageProviders: [imageProvider.Object]);
+
+ var service = NewService(providerManager);
+
+ await service.RefreshMetadata(
+ item,
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh
+ },
+ CancellationToken.None).ConfigureAwait(true);
+
+ Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore);
+ }
+
+ [Fact]
+ public async Task RefreshMetadata_LocalImageValidationThrew_LeavesRefreshDateAlone()
+ {
+ var item = NewStampedTestItem();
+ var stampBefore = item.DateLastRefreshed;
+
+ // A throw here skips the remote image stage altogether, so no image work happened at all.
+ var localImageProvider = new Mock<ILocalImageProvider>(MockBehavior.Loose);
+ localImageProvider.Setup(p => p.Name).Returns("Throwing Local Image Provider");
+ localImageProvider.Setup(p => p.GetImages(It.IsAny<BaseItem>(), It.IsAny<IDirectoryService>()))
+ .Throws(new UnauthorizedAccessException("metadata folder is not readable"));
+
+ var providerManager = NewProviderManager(imageProviders: [localImageProvider.Object]);
+
+ var service = NewService(providerManager);
+
+ await service.RefreshMetadata(
+ item,
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh
+ },
+ CancellationToken.None).ConfigureAwait(true);
+
+ Assert.Equal(stampBefore, item.DateLastRefreshed);
+ }
+
+ private static TestItem NewStampedTestItem()
+ {
+ var item = new TestItem
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Item",
+ PreferredMetadataLanguage = "en",
+ PreferredMetadataCountryCode = "US",
+ DateLastRefreshed = DateTime.UtcNow.AddDays(-60),
+ DateLastSaved = DateTime.UtcNow.AddDays(-60)
+ };
+ item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
+ return item;
+ }
+
+ private static Mock<IProviderManager> NewProviderManager(
+ IMetadataProvider<TestItem>[]? metadataProviders = null,
+ IImageProvider[]? imageProviders = null)
+ {
+ var providerManager = new Mock<IProviderManager>(MockBehavior.Loose);
+ providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>()))
+ .Returns(imageProviders ?? Array.Empty<IImageProvider>());
+ providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(metadataProviders ?? Array.Empty<IMetadataProvider<TestItem>>());
+ providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(Array.Empty<IMetadataSaver>());
+ return providerManager;
+ }
+
+ private static TestItemMetadataService NewService(Mock<IProviderManager> providerManager)
+ {
+ var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose);
+ libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+
+ var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose);
+ itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true);
+
+ return new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object);
+ }
+
/// <summary>
/// Stands in for a real item so the refresh stays off the shared BaseItem statics, which other
/// test classes in this assembly overwrite while xUnit runs them in parallel.
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 248b236df8..d3b82cefcd 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -677,6 +677,8 @@ namespace Jellyfin.Providers.Tests.Manager
var libraryManagerMock = new Mock<ILibraryManager>(MockBehavior.Strict);
libraryManagerMock.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
.Returns(libraryOptions ?? new LibraryOptions());
+ libraryManagerMock.Setup(i => i.GetCollectionFolders(It.IsAny<BaseItem>()))
+ .Returns(new List<Folder>());
libraryManager = libraryManagerMock.Object;
}
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs
new file mode 100644
index 0000000000..cd7ae0f525
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs
@@ -0,0 +1,113 @@
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Providers.MediaInfo;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.MediaInfo;
+
+public class ProbeProviderTests
+{
+ private readonly ProbeProvider _probeProvider;
+
+ public ProbeProviderTests()
+ {
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsAny<string>()))
+ .Returns(MediaProtocol.File);
+
+ // prep BaseItem and Video for calls made that expect managers
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ Video.RecordingsManager = Mock.Of<IRecordingsManager>();
+
+ _probeProvider = new ProbeProvider(
+ mediaSourceManager.Object,
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<IBlurayExaminer>(),
+ Mock.Of<ILocalizationManager>(),
+ Mock.Of<IChapterManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ISubtitleManager>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IFileSystem>(),
+ NullLoggerFactory.Instance,
+ new NamingOptions(),
+ Mock.Of<ILyricManager>(),
+ Mock.Of<IMediaAttachmentRepository>(),
+ Mock.Of<IMediaStreamRepository>());
+ }
+
+ [Fact]
+ public void HasChanged_NeverProbedVideo_ReturnsTrue()
+ {
+ // A probe that threw leaves the item like this while the refresh is stamped as done, and the
+ // file's modification time never changes afterwards, so nothing else would ask for a retry.
+ var item = new Episode { Path = "/media/show/S01E01.mkv" };
+
+ Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_NeverProbedAudio_ReturnsTrue()
+ {
+ var item = new Audio { Path = "/media/music/track.flac" };
+
+ Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Theory]
+ [InlineData(12345L, null)]
+ [InlineData(null, 2480000)]
+ [InlineData(12345L, 2480000)]
+ public void HasChanged_ProbedVideo_ReturnsFalse(long? runTimeTicks, int? totalBitrate)
+ {
+ var item = new Episode
+ {
+ Path = "/media/show/S01E01.mkv",
+ RunTimeTicks = runTimeTicks,
+ TotalBitrate = totalBitrate
+ };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_VirtualItemWithoutMediaInfo_ReturnsFalse()
+ {
+ var item = new Episode { Path = "/media/show/S01E01.mkv", IsVirtualItem = true };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_PlaceHolderWithoutMediaInfo_ReturnsFalse()
+ {
+ var item = new Episode { Path = "/media/show/S01E01.disc", IsPlaceHolder = true };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_ShortcutWithoutMediaInfo_ReturnsFalse()
+ {
+ // A .strm is only probed when remote content probing is enabled, so an empty one is expected.
+ var item = new Episode { Path = "/media/show/S01E01.strm", IsShortcut = true };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs
new file mode 100644
index 0000000000..a73ed61a75
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Playlists;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Playlists;
+
+public sealed class PlaylistItemsProviderTests : IDisposable
+{
+ private const string AccentedFolder = "Música épica";
+ private const string AccentedSong = "Canción.mp3";
+ private const string AsciiSong = "Song.mp3";
+
+ private readonly string _libraryRoot;
+ private readonly PlaylistItemsProvider _sut;
+
+ public PlaylistItemsProviderTests()
+ {
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+
+ _libraryRoot = Path.Combine(Path.GetTempPath(), "jellyfin-playlist-tests", Guid.NewGuid().ToString("N"));
+ var mediaFolder = Path.Combine(_libraryRoot, AccentedFolder);
+ Directory.CreateDirectory(mediaFolder);
+ File.WriteAllText(Path.Combine(mediaFolder, AccentedSong), string.Empty);
+ File.WriteAllText(Path.Combine(mediaFolder, AsciiSong), string.Empty);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.FindByPath(It.IsAny<string>(), It.IsAny<bool?>()))
+ .Returns((string path, bool? _) => new Audio { Id = Guid.NewGuid(), Path = path });
+
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(m => m.MakeAbsolutePath(It.IsAny<string>(), It.IsAny<string>()))
+ .Returns((string folderPath, string filePath) => Path.GetFullPath(Path.Combine(folderPath, filePath)));
+
+ _sut = new PlaylistItemsProvider(NullLogger<PlaylistItemsProvider>.Instance, libraryManager.Object, fileSystem.Object);
+ }
+
+ [Fact]
+ public void GetM3uItems_Utf8Entries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}"], 1);
+
+ /// <summary>
+ /// Playlists written by Windows media players default to the local codepage rather than UTF-8.
+ /// Decoding those as UTF-8 mangles every accented character and loses the entry.
+ /// </summary>
+ [Fact]
+ public void GetM3uItems_LegacyCodepageEntries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.GetEncoding(1252), [$"{AccentedFolder}/{AccentedSong}"], 1);
+
+ /// <summary>
+ /// The files on disk are stored precomposed (NFC), the playlist references them decomposed (NFD).
+ /// </summary>
+ [Fact]
+ public void GetM3uItems_DecomposedEntries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}".Normalize(NormalizationForm.FormD)], 1);
+
+ [Fact]
+ public void GetM3uItems_AsciiEntries_ResolvesPaths()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AsciiSong}"], 1);
+
+ [Fact]
+ public void GetM3uItems_Utf16Entries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.Unicode, [$"{AccentedFolder}/{AccentedSong}"], 1);
+
+ [Fact]
+ public void GetM3uItems_MixedEntries_ResolvesEveryEntry()
+ => AssertResolved(
+ Encoding.GetEncoding(1252),
+ [$"{AccentedFolder}/{AccentedSong}", $"{AccentedFolder}/{AsciiSong}"],
+ 2);
+
+ [Fact]
+ public void GetM3uItems_MissingFile_ResolvesNothing()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/Does not exist.mp3"], 0);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_libraryRoot))
+ {
+ Directory.Delete(_libraryRoot, true);
+ }
+ }
+
+ private void AssertResolved(Encoding encoding, string[] entries, int expected)
+ {
+ var playlistPath = Path.Combine(_libraryRoot, "playlist.m3u");
+ var content = new StringBuilder("#EXTM3U\n");
+ foreach (var entry in entries)
+ {
+ content.Append("#EXTINF:1,Title\n").Append(entry).Append('\n');
+ }
+
+ File.WriteAllBytes(
+ playlistPath,
+ [.. encoding.GetPreamble(), .. encoding.GetBytes(content.ToString())]);
+
+ using var stream = File.OpenRead(playlistPath);
+ var resolved = _sut.GetM3uItems(stream, playlistPath, [_libraryRoot]).ToList();
+
+ Assert.Equal(expected, resolved.Count);
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
index 8926a7b13c..03bad3555e 100644
--- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
@@ -105,6 +105,19 @@ namespace Jellyfin.Providers.Tests.Tmdb
}
[Theory]
+ // An unconfigured size fetches the original image, so it keeps the original resolution.
+ [InlineData(null, true)]
+ [InlineData("", true)]
+ [InlineData("original", true)]
+ [InlineData("Original", true)]
+ [InlineData("w500", false)]
+ [InlineData("original2", false)]
+ public static void IsOriginalImageSize_Valid_Success(string? size, bool expected)
+ {
+ Assert.Equal(expected, TmdbUtils.IsOriginalImageSize(size));
+ }
+
+ [Theory]
[MemberData(nameof(FindBestMatch_Movies_TestData))]
public static void FindBestMatch_Movies_PicksExpected(string description, string name, int year, IReadOnlyList<SearchMovie> results, int expectedId)
{
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/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/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/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/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..93014e7244 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -199,6 +199,16 @@ 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)]
public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration()
@@ -213,6 +223,30 @@ 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)]
@@ -241,6 +275,51 @@ 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]
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..a07e79baa3
--- /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><placeholder>A <see cref="Task"/> representing the asynchronous unit test.</placeholder></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");
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs
new file mode 100644
index 0000000000..f84e28de76
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs
@@ -0,0 +1,36 @@
+using System.Net;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests.Controllers;
+
+public sealed class SyncPlayControllerTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public SyncPlayControllerTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task GetGroups_Unauthorized_ReturnsUnauthorized()
+ {
+ var client = _factory.CreateClient();
+
+ var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetGroups_InvalidToken_ReturnsUnauthorized()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader("invalid-token");
+
+ var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs
new file mode 100644
index 0000000000..81cff49945
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs
@@ -0,0 +1,26 @@
+using Jellyfin.Server.Implementations.Users;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests;
+
+public sealed class HostedServiceRegistrationTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public HostedServiceRegistrationTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public void DeviceAccessHost_IsRegisteredAsHostedService()
+ {
+ _ = _factory.CreateClient();
+
+ var hostedServices = _factory.Services.GetServices<IHostedService>();
+
+ Assert.Contains(hostedServices, service => service is DeviceAccessHost);
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs
new file mode 100644
index 0000000000..25430447d4
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs
@@ -0,0 +1,202 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Migrations.Routines;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+/// <summary>
+/// Covers the references a database carried up from 10.x can still hold against a view the migration
+/// is about to drop. Only ParentId cascades; everything else here is a NO ACTION foreign key that
+/// used to abort the migration, and with it the whole startup.
+/// </summary>
+public sealed class ConsolidateLocalizedUserViewsTests : IDisposable
+{
+ private const string MetadataPath = "/metadata";
+
+ private static readonly Guid _staleId = new("11111111-1111-1111-1111-111111111111");
+ private static readonly Guid _canonicalId = new("22222222-2222-2222-2222-222222222222");
+
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+
+ public ConsolidateLocalizedUserViewsTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task PerformAsync_ItemOwnedByStaleView_MovesItAndDropsTheView()
+ {
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(StaleView());
+ context.BaseItems.Add(new BaseItemEntity { Id = Guid.NewGuid(), Type = "Trailer", OwnerId = _staleId });
+ await context.SaveChangesAsync(Ct);
+ }
+
+ await CreateMigration().PerformAsync(Ct);
+
+ using (var context = CreateDbContext())
+ {
+ Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
+ Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Type == "Trailer", Ct)).OwnerId);
+ }
+ }
+
+ [Fact]
+ public async Task PerformAsync_StaleViewInLinkedChildren_DropsTheLinksAndTheView()
+ {
+ var movieId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(StaleView());
+ context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = "Movie" });
+ context.LinkedChildren.Add(new LinkedChildEntity { ParentId = _staleId, SortOrder = 0, ChildId = movieId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual });
+ context.LinkedChildren.Add(new LinkedChildEntity { ParentId = movieId, SortOrder = 0, ChildId = _staleId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual });
+ await context.SaveChangesAsync(Ct);
+ }
+
+ await CreateMigration().PerformAsync(Ct);
+
+ using (var context = CreateDbContext())
+ {
+ Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
+ Assert.Empty(context.LinkedChildren);
+ Assert.NotNull(await context.BaseItems.FindAsync([movieId], Ct));
+ }
+ }
+
+ [Fact]
+ public async Task PerformAsync_OrphanedAncestry_IsNotResurrectedUnderTheCanonicalView()
+ {
+ var childId = Guid.NewGuid();
+ var orphanId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(StaleView());
+ context.BaseItems.Add(new BaseItemEntity { Id = childId, Type = "Movie", ParentId = _staleId });
+ await context.SaveChangesAsync(Ct);
+
+ context.AncestorIds.Add(new AncestorId { ItemId = childId, ParentItemId = _staleId, Item = null!, ParentItem = null! });
+ await context.SaveChangesAsync(Ct);
+
+ // Written while foreign keys went unenforced: the item behind it is long gone.
+ await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", Ct);
+ context.AncestorIds.Add(new AncestorId { ItemId = orphanId, ParentItemId = _staleId, Item = null!, ParentItem = null! });
+ await context.SaveChangesAsync(Ct);
+ await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", Ct);
+ }
+
+ await CreateMigration().PerformAsync(Ct);
+
+ using (var context = CreateDbContext())
+ {
+ Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
+ Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Id.Equals(childId), Ct)).ParentId);
+ var ancestors = await context.AncestorIds.ToListAsync(Ct);
+ Assert.Equal(new[] { childId }, ancestors.Select(e => e.ItemId));
+ Assert.Equal(_canonicalId, ancestors[0].ParentItemId);
+ }
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static BaseItemEntity StaleView() => new()
+ {
+ Id = _staleId,
+ Type = "MediaBrowser.Controller.Entities.UserView",
+ Path = Path.Combine(MetadataPath, "views", "livetv")
+ };
+
+ private JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(new Mock<IApplicationPaths>().Object, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+
+ private ConsolidateLocalizedUserViews CreateMigration()
+ {
+ var view = new UserView
+ {
+ Id = _staleId,
+ Path = Path.Combine(MetadataPath, "views", "livetv"),
+ Name = "Live TV",
+ ViewType = CollectionType.livetv
+ };
+
+ var applicationPaths = new Mock<IServerApplicationPaths>();
+ applicationPaths.Setup(e => e.InternalMetadataPath).Returns(MetadataPath);
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.Setup(e => e.ApplicationPaths).Returns(applicationPaths.Object);
+
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(e => e.GetValidFilename(It.IsAny<string>())).Returns((string name) => name);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(e => e.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(new List<BaseItem> { view });
+ libraryManager.Setup(e => e.GetNewItemId(It.IsAny<string>(), It.IsAny<Type>()))
+ .Returns(_canonicalId);
+ libraryManager.Setup(e => e.CreateItem(It.IsAny<BaseItem>(), It.IsAny<BaseItem?>()))
+ .Callback((BaseItem item, BaseItem? parent) =>
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = item.Id,
+ Type = item.GetType().FullName!,
+ Path = item.Path
+ });
+ context.SaveChanges();
+ });
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
+
+ return new ConsolidateLocalizedUserViews(
+ new StartupLogger<ConsolidateLocalizedUserViews>(NullLogger<ConsolidateLocalizedUserViews>.Instance),
+ libraryManager.Object,
+ configurationManager.Object,
+ fileSystem.Object,
+ factory.Object);
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs
new file mode 100644
index 0000000000..34eff8a988
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Migrations.Routines;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+/// <summary>
+/// Covers the repair of the PrimaryVersionId the item queries hide an alternate version by,
+/// including the link shapes that would otherwise leave a whole version group hidden.
+/// </summary>
+public sealed class RepairAlternateVersionLinksTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly IApplicationPaths _applicationPaths;
+
+ public RepairAlternateVersionLinksTests()
+ {
+ _applicationPaths = new Mock<IApplicationPaths>().Object;
+
+ // The connection owns the in-memory database, so it stays open for the whole test.
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ [Fact]
+ public async Task PerformAsync_VersionLinkedToPrimary_PointsItAtThePrimary()
+ {
+ var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ Seed(
+ [(primaryId, null), (versionId, null)],
+ [(primaryId, versionId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ AssertIsVersionOf(context, versionId, primaryId);
+ AssertIsPrimary(context, primaryId);
+ }
+
+ [Fact]
+ public async Task PerformAsync_VideosLinkedAsEachOthersVersion_KeepsOneOfThemVisible()
+ {
+ var firstId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ // Each one claims the other as its version, so pointing both at their link would hide the
+ // group in its entirety.
+ Seed(
+ [(firstId, null), (secondId, null)],
+ [(firstId, secondId), (secondId, firstId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ var first = Get(context, firstId);
+ var second = Get(context, secondId);
+
+ var primary = first.PrimaryVersionId is null ? first : second;
+ var version = first.PrimaryVersionId is null ? second : first;
+
+ Assert.Null(primary.PrimaryVersionId);
+ AssertIsPrimary(context, primary.Id);
+ AssertIsVersionOf(context, version.Id, primary.Id);
+ }
+
+ [Fact]
+ public async Task PerformAsync_PrimaryStillPointingAtItsOwnVersion_ClearsTheStalePrimary()
+ {
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
+
+ // The primary was promoted over the version it now heads, but kept the pointer to it: the
+ // repair below would point the version back and leave both hidden.
+ Seed(
+ [(primaryId, versionId), (versionId, null)],
+ [(primaryId, versionId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ AssertIsPrimary(context, primaryId);
+ AssertIsVersionOf(context, versionId, primaryId);
+ }
+
+ [Fact]
+ public async Task PerformAsync_ChainedLinks_PointsEveryVersionAtTheHeadOfTheChain()
+ {
+ var headId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var middleId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var tailId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+
+ // The middle one is a version of the head and a primary of the tail at the same time.
+ Seed(
+ [(headId, null), (middleId, null), (tailId, null)],
+ [(headId, middleId), (middleId, tailId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ AssertIsPrimary(context, headId);
+ AssertIsVersionOf(context, middleId, headId);
+ AssertIsVersionOf(context, tailId, headId);
+ }
+
+ [Fact]
+ public async Task PerformAsync_VersionLinkedToItself_LeavesItVisible()
+ {
+ var itemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ Seed([(itemId, null)], [(itemId, itemId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ Assert.Null(Get(context, itemId).PrimaryVersionId);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ private static void AssertIsPrimary(JellyfinDbContext context, Guid id)
+ {
+ var item = Get(context, id);
+ Assert.Null(item.PrimaryVersionId);
+ Assert.Equal(id.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey);
+ }
+
+ private static void AssertIsVersionOf(JellyfinDbContext context, Guid id, Guid primaryId)
+ {
+ var item = Get(context, id);
+ Assert.Equal(primaryId, item.PrimaryVersionId);
+
+ // Presentation-key grouping has to collapse the version onto its primary as well.
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey);
+ }
+
+ private static BaseItemEntity Get(JellyfinDbContext context, Guid id)
+ => context.BaseItems.AsNoTracking().First(e => e.Id.Equals(id));
+
+ private JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+
+ private void Seed(
+ (Guid Id, Guid? PrimaryVersionId)[] items,
+ (Guid ParentId, Guid ChildId)[] links)
+ {
+ using var context = CreateDbContext();
+
+ foreach (var (id, primaryVersionId) in items)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = "MediaBrowser.Controller.Entities.Movies.Movie",
+ Name = "Movie",
+ PrimaryVersionId = primaryVersionId,
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N", CultureInfo.InvariantCulture)
+ });
+ }
+
+ foreach (var (parentId, childId) in links)
+ {
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = parentId,
+ ChildId = childId,
+ ChildType = LinkedChildType.LinkedAlternateVersion
+ });
+ }
+
+ context.SaveChanges();
+ }
+
+ private Task PerformAsync()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var migration = new RepairAlternateVersionLinks(
+ new StartupLogger<RepairAlternateVersionLinks>(NullLogger<RepairAlternateVersionLinks>.Instance),
+ factory.Object);
+
+ return migration.PerformAsync(CancellationToken.None);
+ }
+}