aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs330
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs138
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs182
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs6
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs206
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs151
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs96
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs553
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs526
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs287
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs118
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs154
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs85
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj1
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs285
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs89
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs104
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs63
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs216
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs54
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs83
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs80
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs92
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs80
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs156
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs24
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs93
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs240
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs142
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs181
31 files changed, 4793 insertions, 24 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
new file mode 100644
index 0000000000..6a3dcab57a
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Dto;
+using Emby.Server.Implementations.Playlists;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Dto;
+
+public class DtoServiceImageInheritanceTests
+{
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/playlists-custom.jpg",
+ DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.NotNull(dto.ParentPrimaryImageItemId);
+ Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag);
+ Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary));
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos = []
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ Assert.Null(dto.ParentPrimaryImageTag);
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_MusicAlbums_ResolveInheritedThumbFromArtistBatch_WithoutPerAlbumLookup()
+ {
+ var artist = new MusicArtist
+ {
+ Id = Guid.NewGuid(),
+ Name = "Some Artist",
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Thumb,
+ Path = "/images/artist-thumb.jpg",
+ DateModified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ static MusicAlbum MakeAlbum() => new MusicAlbum
+ {
+ Id = Guid.NewGuid(),
+ Name = "Album",
+ AlbumArtists = ["Some Artist"],
+ ImageInfos = []
+ };
+
+ var libraryManager = new Mock<ILibraryManager>();
+
+ // DtoService resolves every album-artist name in ONE batch (GetArtists). The album's inherited
+ // Thumb/Backdrop images must come from that batch, not a per-album GetArtist/GetItemList lookup
+ // (the N+1). GetArtist is intentionally left unset: a regression to the per-album path would
+ // resolve no artist and fail the assertions below.
+ libraryManager
+ .Setup(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()))
+ .Returns(new Dictionary<string, MusicArtist[]>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Some Artist"] = [artist]
+ });
+
+ var dtoService = BuildDtoService(libraryManager);
+
+ var dtos = dtoService.GetBaseItemDtos([MakeAlbum(), MakeAlbum()], new DtoOptions(false));
+
+ Assert.Equal(2, dtos.Count);
+ foreach (var dto in dtos)
+ {
+ Assert.Equal(artist.Id, dto.ParentThumbItemId);
+ Assert.Equal("/images/artist-thumb.jpg", dto.ParentThumbImageTag);
+ }
+
+ // The artist lookup is batched once for the whole set, never once per album.
+ libraryManager.Verify(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()), Times.Once);
+ libraryManager.Verify(x => x.GetArtist(It.IsAny<string>(), It.IsAny<DtoOptions>()), Times.Never);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_Items_ResolvePeopleFromBatch_WithoutPerItemLookup()
+ {
+ static MusicAlbum MakeAlbum() => new MusicAlbum
+ {
+ Id = Guid.NewGuid(),
+ Name = "Album",
+ ImageInfos = []
+ };
+
+ var albumOne = MakeAlbum();
+ var albumTwo = MakeAlbum();
+
+ var libraryManager = new Mock<ILibraryManager>();
+
+ // DtoService resolves people for every item in ONE batch (GetPeopleByItems) before the
+ // per-item loop. A regression to the per-item path would call GetPeople(BaseItem) once per
+ // item (the N+1); it is intentionally left unset so such a regression fails here.
+ libraryManager
+ .Setup(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()))
+ .Returns(new Dictionary<Guid, IReadOnlyList<PersonInfo>>
+ {
+ [albumOne.Id] = [new PersonInfo { ItemId = albumOne.Id, Name = "Some Actor", Type = PersonKind.Actor }],
+ [albumTwo.Id] = [new PersonInfo { ItemId = albumTwo.Id, Name = "Some Actor", Type = PersonKind.Actor }]
+ });
+
+ // AttachPeople still resolves each distinct name to its Person entity to attach images.
+ libraryManager
+ .Setup(x => x.GetPerson("Some Actor"))
+ .Returns(new Person { Id = Guid.NewGuid(), Name = "Some Actor" });
+
+ var dtoService = BuildDtoService(libraryManager);
+
+ var options = new DtoOptions(false) { Fields = [ItemFields.People] };
+ var dtos = dtoService.GetBaseItemDtos([albumOne, albumTwo], options);
+
+ Assert.Equal(2, dtos.Count);
+ foreach (var dto in dtos)
+ {
+ Assert.NotNull(dto.People);
+ Assert.Single(dto.People);
+ Assert.Equal("Some Actor", dto.People[0].Name);
+ }
+
+ // People are batched once for the whole set, never once per item.
+ libraryManager.Verify(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()), Times.Once);
+ libraryManager.Verify(x => x.GetPeople(It.IsAny<BaseItem>()), Times.Never);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_Videos_ResolveMediaSourceCountFromBatch_WithoutPerItemLookup()
+ {
+ static Movie MakeMovie() => new Movie
+ {
+ Id = Guid.NewGuid(),
+ Name = "Movie",
+ ImageInfos = []
+ };
+
+ var movieOne = MakeMovie();
+ var movieTwo = MakeMovie();
+
+ var libraryManager = new Mock<ILibraryManager>();
+
+ // DtoService detects which videos own alternate versions in ONE batch
+ // (GetItemIdsWithAlternateVersions) before the per-item loop. Videos absent from that set have a
+ // single media source, so the per-item GetLinkedAlternateVersions/GetLocalAlternateVersionIds
+ // queries (the N+1) must be skipped entirely. Here neither movie has alternate versions.
+ libraryManager
+ .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()))
+ .Returns(new HashSet<Guid>());
+
+ var dtoService = BuildDtoService(libraryManager);
+
+ var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] };
+ var dtos = dtoService.GetBaseItemDtos([movieOne, movieTwo], options);
+
+ Assert.Equal(2, dtos.Count);
+
+ // A single media source is the default, so the count is left unset (the client treats null as one).
+ foreach (var dto in dtos)
+ {
+ Assert.Null(dto.MediaSourceCount);
+ }
+
+ // The alternate-version check is batched once for the whole set, and the per-item lookups are
+ // never reached because the batch already ruled out alternate versions.
+ libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once);
+ libraryManager.Verify(x => x.GetLinkedAlternateVersions(It.IsAny<Video>()), Times.Never);
+ libraryManager.Verify(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()), Times.Never);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_VideoInAlternateVersionBatch_ResolvesRealCount()
+ {
+ var movie = new Movie
+ {
+ Id = Guid.NewGuid(),
+ Name = "Movie",
+ ImageInfos = []
+ };
+
+ var libraryManager = new Mock<ILibraryManager>();
+
+ // This movie IS in the batch set, so the fast path must not short-circuit it: the per-item
+ // lookups still run and the count is computed exactly as it was before batching. Two linked
+ // alternate versions plus the movie itself is a count of three.
+ libraryManager
+ .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()))
+ .Returns(new HashSet<Guid> { movie.Id });
+ libraryManager
+ .Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>()))
+ .Returns([new Movie { Id = Guid.NewGuid() }, new Movie { Id = Guid.NewGuid() }]);
+ libraryManager
+ .Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()))
+ .Returns([]);
+
+ var dtoService = BuildDtoService(libraryManager);
+
+ var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] };
+ var dtos = dtoService.GetBaseItemDtos([movie], options);
+
+ Assert.Single(dtos);
+ Assert.Equal(3, dtos[0].MediaSourceCount);
+ libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once);
+ }
+
+ private static DtoService BuildDtoService(BaseItem displayParent)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(x => x.GetItemById(displayParent.Id))
+ .Returns(displayParent);
+ return BuildDtoService(libraryManager);
+ }
+
+ private static DtoService BuildDtoService(Mock<ILibraryManager> libraryManager)
+ {
+ var userDataManager = new Mock<IUserDataManager>();
+ var imageProcessor = new Mock<IImageProcessor>();
+ var providerManager = new Mock<IProviderManager>();
+ var recordingsManager = new Mock<IRecordingsManager>();
+ var appHost = new Mock<IApplicationHost>();
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ var liveTvManager = new Mock<ILiveTvManager>();
+ var trickplayManager = new Mock<ITrickplayManager>();
+ var chapterManager = new Mock<IChapterManager>();
+ var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
+
+ imageProcessor
+ .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
+ .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
+
+ // Video.IsActiveRecording() dereferences this static during DTO building.
+ Video.RecordingsManager = recordingsManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return new DtoService(
+ logger.Object,
+ libraryManager.Object,
+ userDataManager.Object,
+ imageProcessor.Object,
+ providerManager.Object,
+ recordingsManager.Object,
+ appHost.Object,
+ mediaSourceManager.Object,
+ new Lazy<ILiveTvManager>(() => liveTvManager.Object),
+ trickplayManager.Object,
+ chapterManager.Object);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
new file mode 100644
index 0000000000..9c247d54b9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -0,0 +1,138 @@
+using System;
+using Emby.Server.Implementations.Dto;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Dto;
+
+public class DtoServiceTests
+{
+ private readonly Mock<ILibraryManager> _libraryManagerMock;
+ private readonly DtoService _dtoService;
+
+ public DtoServiceTests()
+ {
+ _libraryManagerMock = new Mock<ILibraryManager>();
+
+ var imageProcessor = new Mock<IImageProcessor>();
+ // Deterministic tag derived from the image so each item gets a distinct, assertable tag.
+ imageProcessor
+ .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
+ .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path);
+
+ var appHost = new Mock<IApplicationHost>();
+ appHost.Setup(x => x.SystemId).Returns("test-server");
+
+ // Video.SourceType probes the active-recording manager; provide one so it doesn't NRE.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
+ _dtoService = new DtoService(
+ NullLogger<DtoService>.Instance,
+ _libraryManagerMock.Object,
+ new Mock<IUserDataManager>().Object,
+ imageProcessor.Object,
+ new Mock<IProviderManager>().Object,
+ new Mock<IRecordingsManager>().Object,
+ appHost.Object,
+ new Mock<IMediaSourceManager>().Object,
+ new Lazy<ILiveTvManager>(() => new Mock<ILiveTvManager>().Object),
+ new Mock<ITrickplayManager>().Object,
+ new Mock<IChapterManager>().Object);
+
+ // Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager.
+ BaseItem.LibraryManager = _libraryManagerMock.Object;
+ }
+
+ [Fact]
+ public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage()
+ {
+ var (episode, season, _) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] };
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio,
+ // and clients decide per view whether to prefer the parent/series poster over the episode still.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
+ Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ // Aspect ratio stays the episode's own image, not the poster's.
+ Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster()
+ {
+ var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
+ var options = new DtoOptions(false);
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // Episode image is retained; ParentPrimaryImage falls back to the series poster.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
+ Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary()
+ {
+ var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false);
+ var options = new DtoOptions(false);
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // With no season or series poster there is nothing to attach; the episode keeps its own primary.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ }
+
+ private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true)
+ {
+ // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
+ // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
+ var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
+ if (seriesHasPoster)
+ {
+ series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+ }
+
+ var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
+ if (seasonHasPoster)
+ {
+ season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0);
+ }
+
+ var episode = new Episode
+ {
+ Id = Guid.NewGuid(),
+ Name = "Episode",
+ SeasonId = season.Id,
+ SeriesId = series.Id
+ };
+ episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0);
+
+ _libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season);
+ _libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series);
+
+ return (episode, season, series);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
new file mode 100644
index 0000000000..66c392a6ad
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+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.Implementations.FullSystemBackup;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup;
+
+/// <summary>
+/// Tests for <see cref="BackupService"/>, in particular that a single row of corrupt
+/// <see cref="KeyframeData"/> (e.g. malformed <c>KeyframeTicks</c> JSON) does not abort
+/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216.
+/// </summary>
+public sealed class BackupServiceTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly string _testRoot;
+ private readonly string _backupPath;
+ private readonly string _configurationDirectoryPath;
+
+ public BackupServiceTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ // Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted
+ // windows-latest runners, the system temp directory lives on the constrained C: drive, which can have
+ // less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory
+ // is under the repo checkout (the much larger D: drive on Windows runners) on all platforms.
+ _testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N"));
+ _backupPath = Path.Combine(_testRoot, "Backup");
+ _configurationDirectoryPath = Path.Combine(_testRoot, "Config");
+ Directory.CreateDirectory(_backupPath);
+ Directory.CreateDirectory(_configurationDirectoryPath);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+
+ if (Directory.Exists(_testRoot))
+ {
+ Directory.Delete(_testRoot, true);
+ }
+ }
+
+ [Fact]
+ public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+
+ await using (var ctx = CreateDbContext())
+ {
+ // A healthy item + keyframe row, written the normal way.
+ ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie"));
+ ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie"));
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ ctx.KeyframeData.Add(new KeyframeData
+ {
+ ItemId = validItemId,
+ TotalDuration = 60_000,
+ KeyframeTicks = [0, 1000, 2000]
+ });
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ // Simulate a corrupted database row: truncated JSON array for KeyframeTicks,
+ // written directly via SQL to bypass EF's normal (well-formed) write path.
+ await ctx.Database.ExecuteSqlInterpolatedAsync(
+ $"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})",
+ cancellationToken).ConfigureAwait(true);
+ }
+
+ var backupService = CreateBackupService();
+
+ var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true);
+
+ Assert.True(File.Exists(manifest.Path));
+
+ using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true);
+ var keyframeEntry = archive.GetEntry("Database/KeyframeData.json");
+ Assert.NotNull(keyframeEntry);
+
+ await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true);
+ using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true);
+
+ var rows = document.RootElement.EnumerateArray().ToList();
+
+ // The corrupt row must be skipped, but the valid row must still make it into the backup.
+ var singleRow = Assert.Single(rows);
+ Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid());
+ }
+
+ private BackupService CreateBackupService()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
+
+ var applicationHost = new Mock<IServerApplicationHost>();
+ applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0));
+
+ var applicationPaths = new Mock<IServerApplicationPaths>();
+ applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath);
+ applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath);
+ applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data"));
+ applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root"));
+ applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata"));
+ applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault"));
+
+ var jellyfinDatabaseProvider = new Mock<IJellyfinDatabaseProvider>();
+ jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
+ jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny<JellyfinDbContext>(), It.IsAny<System.Collections.Generic.IEnumerable<string>>())).Returns(Task.CompletedTask);
+
+ var applicationLifetime = new Mock<IHostApplicationLifetime>();
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.IsScanRunning).Returns(false);
+
+ return new BackupService(
+ NullLogger<BackupService>.Instance,
+ factory.Object,
+ applicationHost.Object,
+ applicationPaths.Object,
+ jellyfinDatabaseProvider.Object,
+ applicationLifetime.Object,
+ libraryManager.Object);
+ }
+
+ private static BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Movie",
+ Name = name,
+ PresentationUniqueKey = id.ToString("N"),
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
index c06279af2d..6cadfacce8 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
@@ -10,7 +10,7 @@ using Xunit;
namespace Jellyfin.Server.Implementations.Tests.IO;
-public class ManagedFileSystemTests
+public partial class ManagedFileSystemTests
{
private readonly IFixture _fixture;
private readonly ManagedFileSystem _sut;
@@ -117,7 +117,7 @@ public class ManagedFileSystemTests
}
[SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")]
- [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)]
+ [LibraryImport("libc", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)]
- private static extern int symlink(string target, string linkpath);
+ private static partial int symlink([MarshalAs(UnmanagedType.LPStr)] string target, [MarshalAs(UnmanagedType.LPStr)] string linkpath);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
new file mode 100644
index 0000000000..2d520f8b8b
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -0,0 +1,206 @@
+#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes.
+
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Verifies that the alternate-version-aware query shapes used by the resume filter
+/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
+/// and evaluate correctly on the SQLite provider.
+/// </summary>
+public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture
+{
+ public AlternateVersionQueryTranslationTests()
+ {
+ }
+
+ [Fact]
+ public void ResumeFilter_VersionProgress_SurfacesPlayedVersion()
+ {
+ Guid userId, primaryId, versionId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, versionId, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var inProgress = ctx.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, versionId, otherId };
+
+ // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([versionId], resumable);
+
+ // The not-resumable direction keeps primaries only.
+ var resumableMovieIds = inProgress
+ .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
+ var notResumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .Where(e => !resumableMovieIds.Contains(e.Id))
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([otherId], notResumable);
+ }
+ }
+
+ [Fact]
+ public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion()
+ {
+ Guid userId, primaryId, versionAId, versionBId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var inProgress = ctx.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ var seededIds = new[] { primaryId, versionAId, versionBId };
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the
+ // expression translates on SQLite and that two versions sharing an identical LastPlayedDate
+ // collapse to a single row instead of double-listing the item in Continue Watching.
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
+ .Select(e => e.Id)
+ .ToList();
+
+ var survivor = Assert.Single(resumable);
+ Assert.Contains(survivor, new[] { versionAId, versionBId });
+ }
+ }
+
+ [Fact]
+ public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate()
+ {
+ Guid userId, primaryId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, _, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, otherId };
+
+ // Mirrors the DatePlayed mapping in OrderMapper.
+ var ordered = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .OrderByDescending(e => ctx.UserData
+ .Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
+ .Max(f => f.LastPlayedDate))
+ .Select(e => e.Id)
+ .ToList();
+
+ // The movie whose only progress is on its alternate version sorts before the unplayed one.
+ Assert.Equal([primaryId, otherId], ordered);
+ }
+ }
+
+ private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ ctx.BaseItems.AddRange(primary, version, other);
+
+ // Progress only on the alternate version.
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = version.Id,
+ Item = version,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = version.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, version.Id, other.Id);
+ }
+
+ private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ ctx.BaseItems.AddRange(primary, versionA, versionB);
+
+ // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break.
+ var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionA.Id,
+ Item = versionA,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionA.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = tied
+ });
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionB.Id,
+ Item = versionB,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionB.Id.ToString("N"),
+ PlaybackPositionTicks = 2000,
+ LastPlayedDate = tied
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, versionA.Id, versionB.Id);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
new file mode 100644
index 0000000000..0cee47f660
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
@@ -0,0 +1,151 @@
+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 BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// The by-name endpoints (artists, album artists, genres, studios) all funnel through
+/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count
+/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total.
+/// </summary>
+public sealed class BaseItemRepositoryByNameTotalCountTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryByNameTotalCountTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetArtists_WithoutLimit_ReportsTotalRecordCount()
+ {
+ SeedArtists(3);
+
+ var result = _repository.GetArtists(CreateQuery(limit: null));
+
+ Assert.Equal(3, result.Items.Count);
+ Assert.Equal(3, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_WithLimit_ReportsTotalBeyondThePage()
+ {
+ SeedArtists(3);
+
+ var result = _repository.GetArtists(CreateQuery(limit: 2));
+
+ Assert.Equal(2, result.Items.Count);
+ Assert.Equal(3, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_TotalRecordCountDisabled_StaysZero()
+ {
+ SeedArtists(3);
+
+ var query = CreateQuery(limit: null);
+ query.EnableTotalRecordCount = false;
+
+ var result = _repository.GetArtists(query);
+
+ Assert.Equal(3, result.Items.Count);
+ Assert.Equal(0, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_WithoutLimit_DoesNotMutateCallerQuery()
+ {
+ SeedArtists(1);
+
+ var query = CreateQuery(limit: null);
+ Assert.True(query.EnableTotalRecordCount);
+
+ _repository.GetArtists(query);
+
+ // The repository used to flip this flag on the caller's own query object, so a
+ // reused query silently lost its total on every subsequent call.
+ Assert.True(query.EnableTotalRecordCount);
+ }
+
+ private static InternalItemsQuery CreateQuery(int? limit)
+ {
+ return new InternalItemsQuery(new User("test", "auth", "reset"))
+ {
+ Limit = limit
+ };
+ }
+
+ /// <summary>
+ /// Creates <paramref name="count"/> artists, each credited on one song, which is what
+ /// makes them visible to the item-value join behind the by-name endpoints.
+ /// </summary>
+ private void SeedArtists(int count)
+ {
+ using var ctx = CreateDbContext();
+
+ for (var i = 0; i < count; i++)
+ {
+ var name = $"Artist {i}";
+ var cleanName = name.ToLowerInvariant();
+
+ var artistId = Guid.Parse($"aaaaaaaa-0000-0000-0000-{i:D12}");
+ var songId = Guid.Parse($"55555555-0000-0000-0000-{i:D12}");
+ var valueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}");
+
+ var artist = new BaseItemEntity
+ {
+ Id = artistId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist],
+ Name = name,
+ CleanName = cleanName,
+ PresentationUniqueKey = artistId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ };
+
+ var song = new BaseItemEntity
+ {
+ Id = songId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio],
+ Name = $"Song {i}",
+ CleanName = $"song {i}",
+ PresentationUniqueKey = songId.ToString("N"),
+ MediaType = "Audio",
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = valueId,
+ Type = ItemValueType.Artist,
+ Value = name,
+ CleanValue = cleanName
+ };
+
+ ctx.BaseItems.Add(artist);
+ ctx.BaseItems.Add(song);
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = songId,
+ ItemValueId = valueId,
+ Item = song,
+ ItemValue = itemValue
+ });
+ }
+
+ ctx.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
new file mode 100644
index 0000000000..535961a66c
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -0,0 +1,96 @@
+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 MediaBrowser.Model.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly string _movieTypeName;
+
+ public BaseItemRepositoryGroupingTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetItemList_VersionGroup_ReturnsPrimaryVersion()
+ {
+ // The alternate version sorts before the primary by id, so a plain Min(Id) per
+ // presentation key would wrongly pick the alternate as the group representative.
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var alternateId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var presentationKey = primaryId.ToString("N");
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie", presentationKey, null));
+ ctx.BaseItems.Add(CreateMovieEntity(alternateId, "Movie - 1080p", presentationKey, primaryId));
+ ctx.SaveChanges();
+ }
+
+ var result = _repository.GetItemList(CreateQuery());
+
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_GroupWithoutPrimary_FallsBackToMinId()
+ {
+ var firstId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+ var otherPrimaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var presentationKey = otherPrimaryId.ToString("N");
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(firstId, "Movie", presentationKey, otherPrimaryId));
+ ctx.BaseItems.Add(CreateMovieEntity(secondId, "Movie - 4K", presentationKey, otherPrimaryId));
+ ctx.SaveChanges();
+ }
+
+ var result = _repository.GetItemList(CreateQuery());
+
+ var item = Assert.Single(result);
+ Assert.Equal(firstId, item.Id);
+ }
+
+ private static InternalItemsQuery CreateQuery()
+ {
+ // IncludeOwnedItems keeps the alternate version rows in the query so the
+ // grouping collapse is what picks the group representative.
+ return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie],
+ IncludeOwnedItems = true
+ };
+ }
+
+ private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ PresentationUniqueKey = presentationKey,
+ PrimaryVersionId = primaryVersionId,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
new file mode 100644
index 0000000000..4e8d84850b
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs
@@ -0,0 +1,553 @@
+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 the filters resolving "folders with a matching descendant" through
+/// <see cref="DescendantQueryHelper.GetFolderIdsMatching"/>, positive and negated.
+/// </summary>
+public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture
+{
+ private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
+ private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+
+ private readonly BaseItemRepository _repository;
+
+ private readonly Guid _library = Guid.NewGuid();
+ private readonly Guid _withSubtitles = Guid.NewGuid();
+ private readonly Guid _withoutSubtitles = Guid.NewGuid();
+ private readonly Guid _collection = Guid.NewGuid();
+ private readonly Guid _linkedSeries = Guid.NewGuid();
+ private readonly Guid _linkedEpisode = Guid.NewGuid();
+
+ // A version group in a library of its own, so it cannot move the assertions above: an SD primary
+ // that carries nothing, and a 4K second file carrying the subtitles, chapter image and audio.
+ private readonly Guid _versionLibrary = Guid.NewGuid();
+ private readonly Guid _versionedMovie = Guid.NewGuid();
+ private readonly Guid _alternateVersion = Guid.NewGuid();
+
+ // A series in the same library, so the folder branch of the resolution filter has a version group
+ // to reach through as well: an SD episode whose second file is 4K.
+ private readonly Guid _versionedSeries = Guid.NewGuid();
+ private readonly Guid _versionedEpisode = Guid.NewGuid();
+ private readonly Guid _episodeAlternate = Guid.NewGuid();
+
+ // An unprobed primary: only its second file carries dimensions, and they are SD.
+ private readonly Guid _unprobedMovie = Guid.NewGuid();
+ private readonly Guid _unprobedAlternate = Guid.NewGuid();
+
+ // A plain SD movie with no second file, as the control the version groups are read against.
+ private readonly Guid _sdMovie = Guid.NewGuid();
+
+ // An unprobed primary whose only second file is HD, so the HD bucket has to place it off nulls.
+ private readonly Guid _hdOnlyByVersion = Guid.NewGuid();
+ private readonly Guid _hdOnlyAlternate = Guid.NewGuid();
+
+ // Three files for one movie: the HD one would place it in the HD bucket on its own, the 4K one has
+ // to win. Only a group holding both can tell the HD bucket's upper guard from its lower one.
+ private readonly Guid _threeWayMovie = Guid.NewGuid();
+ private readonly Guid _threeWayHd = Guid.NewGuid();
+ private readonly Guid _threeWay4K = Guid.NewGuid();
+
+ public BaseItemRepositoryStreamFilterTests()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ Seed(ctx);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void HasSubtitles_MatchesTheItemAndItsParentFolder()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
+
+ Assert.Contains(_withSubtitles, ids);
+ // The library is a folder, and it has a descendant with subtitles.
+ Assert.Contains(_library, ids);
+ Assert.DoesNotContain(_withoutSubtitles, ids);
+ }
+
+ [Fact]
+ public void HasSubtitles_Negated_ExcludesTheItemAndItsParentFolder()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
+
+ Assert.Contains(_withoutSubtitles, ids);
+ Assert.DoesNotContain(_withSubtitles, ids);
+ Assert.DoesNotContain(_library, ids);
+ }
+
+ [Fact]
+ public void SubtitleLanguages_MatchesTheRequestedLanguageOnly()
+ {
+ Assert.Contains(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
+ Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
+ }
+
+ [Fact]
+ public void HasNoSubtitleTrackWithLanguage_ExcludesTheMatchingItemAndFolder()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
+
+ Assert.Contains(_withoutSubtitles, ids);
+ Assert.DoesNotContain(_withSubtitles, ids);
+ Assert.DoesNotContain(_library, ids);
+ }
+
+ [Fact]
+ public void HasSubtitles_MatchesACollectionLinkingAFolder()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
+
+ Assert.Contains(_linkedSeries, ids);
+ Assert.Contains(_collection, ids);
+ }
+
+ [Fact]
+ public void HasSubtitles_Negated_ExcludesACollectionLinkingAFolder()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
+
+ Assert.DoesNotContain(_linkedSeries, ids);
+ Assert.DoesNotContain(_collection, ids);
+ }
+
+ [Fact]
+ public void HasChapterImages_MatchesTheItemAndItsParentFolder()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true });
+
+ Assert.Contains(_withSubtitles, ids);
+ Assert.Contains(_library, ids);
+ Assert.DoesNotContain(_withoutSubtitles, ids);
+ }
+
+ [Fact]
+ public void HasSubtitles_MatchesAnItemWhoseAlternateVersionCarriesThem()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
+
+ Assert.Contains(_versionedMovie, ids);
+ Assert.Contains(_versionLibrary, ids);
+ // The second file is never listed on its own, which is why its tracks have to count for the primary.
+ Assert.DoesNotContain(_alternateVersion, ids);
+ }
+
+ [Fact]
+ public void HasSubtitles_Negated_ExcludesAnItemWhoseAlternateVersionCarriesThem()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
+
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.DoesNotContain(_versionLibrary, ids);
+ }
+
+ [Fact]
+ public void SubtitleLanguages_MatchesTheLanguageOnAnAlternateVersion()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
+ }
+
+ [Fact]
+ public void HasNoSubtitleTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
+
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.DoesNotContain(_versionLibrary, ids);
+ }
+
+ [Fact]
+ public void AudioLanguages_MatchesTheLanguageOnAnAlternateVersion()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { AudioLanguages = ["fre"] }));
+ }
+
+ [Fact]
+ public void HasNoAudioTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
+ {
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = "fre" }));
+ }
+
+ [Fact]
+ public void HasChapterImages_MatchesAnItemWhoseAlternateVersionCarriesThem()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true }));
+ }
+
+ [Fact]
+ public void Is4K_MatchesAnItemWhoseAlternateVersionIs4K()
+ {
+ // The primary file is SD; the resolution a caller can actually play is the 4K second file's.
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
+ }
+
+ [Fact]
+ public void MinWidth_MatchesAnItemWhoseAlternateVersionIsWideEnough()
+ {
+ Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
+ Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
+ }
+
+ [Fact]
+ public void MaxWidth_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
+ {
+ // The SD primary is narrow enough on its own, but the 4K second file is what a caller would play.
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
+ Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
+ }
+
+ [Fact]
+ public void MaxHeight_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
+ {
+ Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
+ Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
+ }
+
+ [Fact]
+ public void IsHD_False_ExcludesAnSdPrimaryWhoseAlternateVersionIsBetter()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false });
+
+ // 720x480 on its own, but the version group tops out at 4K.
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.Contains(_sdMovie, ids);
+ }
+
+ [Fact]
+ public void IsHD_False_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
+ {
+ // The primary carries no dimensions at all; the SD second file is the group's best.
+ Assert.Contains(_unprobedMovie, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
+ }
+
+ [Fact]
+ public void IsHD_True_ExcludesAnItemWhoseVersionGroupReaches4K()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true });
+
+ Assert.DoesNotContain(_versionedMovie, ids);
+ Assert.DoesNotContain(_unprobedMovie, ids);
+ // The 1920-wide second file alone would say HD; the 4K third file is the group's best.
+ Assert.DoesNotContain(_threeWayMovie, ids);
+ }
+
+ [Fact]
+ public void Is4K_MatchesAnItemWhoseVersionGroupHoldsBothHdAnd4K()
+ {
+ Assert.Contains(_threeWayMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
+ }
+
+ [Fact]
+ public void IsHD_True_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
+ {
+ // The primary carries no dimensions of its own; the HD second file is the group's best.
+ Assert.Contains(_hdOnlyByVersion, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true }));
+ }
+
+ [Fact]
+ public void Is4K_MatchesTheSeriesOfAnEpisodeWhoseAlternateVersionIs4K()
+ {
+ // The folder branch buckets a descendant the same way the item branch buckets a top-level item.
+ Assert.Contains(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
+ }
+
+ [Fact]
+ public void IsHD_False_ExcludesTheSeriesOfAnSdEpisodeWithABetterAlternateVersion()
+ {
+ // Before the version group was consulted on descendants too, the SD episode alone matched here
+ // while the same pair at top level did not.
+ Assert.DoesNotContain(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
+ }
+
+ [Theory]
+ [InlineData("und")]
+ [InlineData("UND")]
+ public void HasNoAudioTrackWithLanguage_TreatsUndeterminedCaseInsensitively(string language)
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = language });
+
+ // The alternate version carries an audio track with no language, which is what "und" stands for,
+ // so the item it is reported against does have one.
+ Assert.DoesNotContain(_unprobedMovie, ids);
+ Assert.Contains(_versionedMovie, ids);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _withSubtitles, Type = MovieType, Name = "With subtitles" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _withoutSubtitles, Type = MovieType, Name = "Without subtitles" });
+
+ foreach (var itemId in new[] { _withSubtitles, _withoutSubtitles })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _library,
+ Item = null!,
+ ParentItem = null!
+ });
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = itemId,
+ StreamIndex = 0,
+ StreamType = MediaStreamTypeEntity.Video,
+ Item = null!
+ });
+ }
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _withSubtitles,
+ StreamIndex = 1,
+ StreamType = MediaStreamTypeEntity.Subtitle,
+ Language = "ger",
+ Item = null!
+ });
+
+ // A collection linking a folder: the match is two edges away, one link then one closure hop.
+ context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _linkedSeries, Type = FolderType, Name = "Linked series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _linkedEpisode, Type = MovieType, Name = "Linked episode" });
+
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = _linkedEpisode,
+ ParentItemId = _linkedSeries,
+ Item = null!,
+ ParentItem = null!
+ });
+
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _collection,
+ ChildId = _linkedSeries,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 0
+ });
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _linkedEpisode,
+ StreamIndex = 0,
+ StreamType = MediaStreamTypeEntity.Subtitle,
+ Language = "ger",
+ Item = null!
+ });
+
+ context.Chapters.Add(new Chapter
+ {
+ ItemId = _withSubtitles,
+ ChapterIndex = 0,
+ StartPositionTicks = 0,
+ ImagePath = "/chapter.jpg",
+ Item = null!
+ });
+
+ SeedVersionGroup(context);
+
+ context.SaveChanges();
+ }
+
+ // An SD primary whose only extras live on a 4K second file, so every filter has to reach through
+ // PrimaryVersionId to answer correctly.
+ private void SeedVersionGroup(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionLibrary, Type = FolderType, Name = "Version library", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionedMovie, Type = MovieType, Name = "Versioned movie", Width = 720, Height = 480 });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _alternateVersion,
+ Type = MovieType,
+ Name = "Versioned movie 4K",
+ PrimaryVersionId = _versionedMovie,
+ Width = 3840,
+ Height = 2160
+ });
+
+ foreach (var itemId in new[] { _versionedMovie, _alternateVersion })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _alternateVersion,
+ StreamIndex = 0,
+ StreamType = MediaStreamTypeEntity.Subtitle,
+ Language = "ger",
+ Item = null!
+ });
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _alternateVersion,
+ StreamIndex = 1,
+ StreamType = MediaStreamTypeEntity.Audio,
+ Language = "fre",
+ Item = null!
+ });
+
+ SeedVersionedSeries(context);
+ SeedUnprobedVersionGroup(context);
+
+ context.Chapters.Add(new Chapter
+ {
+ ItemId = _alternateVersion,
+ ChapterIndex = 0,
+ StartPositionTicks = 0,
+ ImagePath = "/alternate-chapter.jpg",
+ Item = null!
+ });
+ }
+
+ // The same SD primary / 4K second file pair one level down, so the resolution filter has to answer
+ // for the series off its descendants.
+ private void SeedVersionedSeries(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionedSeries, Type = FolderType, Name = "Versioned series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _versionedEpisode, Type = MovieType, Name = "Versioned episode", Width = 720, Height = 480 });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _episodeAlternate,
+ Type = MovieType,
+ Name = "Versioned episode 4K",
+ PrimaryVersionId = _versionedEpisode,
+ Width = 3840,
+ Height = 2160
+ });
+
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = _versionedSeries,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+
+ foreach (var itemId in new[] { _versionedEpisode, _episodeAlternate })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionedSeries,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ // A primary that was never probed, so only its second file can place it in a bucket. Its audio track
+ // declares no language, which is what the "und" filters stand in for.
+ private void SeedUnprobedVersionGroup(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _sdMovie, Type = MovieType, Name = "SD movie", Width = 720, Height = 480 });
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = _sdMovie,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+
+ context.BaseItems.Add(new BaseItemEntity { Id = _unprobedMovie, Type = MovieType, Name = "Unprobed movie" });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _unprobedAlternate,
+ Type = MovieType,
+ Name = "Unprobed movie SD",
+ PrimaryVersionId = _unprobedMovie,
+ Width = 720,
+ Height = 480
+ });
+
+ foreach (var itemId in new[] { _unprobedMovie, _unprobedAlternate })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = _unprobedAlternate,
+ StreamIndex = 0,
+ StreamType = MediaStreamTypeEntity.Audio,
+ Item = null!
+ });
+
+ SeedMixedVersionGroups(context);
+ }
+
+ // The two groups that separate the HD bucket's lower bound from its upper one: one that only a 4K
+ // third file keeps out of HD, and one that only an HD second file puts into it.
+ private void SeedMixedVersionGroups(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _threeWayMovie, Type = MovieType, Name = "Three-way movie", Width = 720, Height = 480 });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _threeWayHd,
+ Type = MovieType,
+ Name = "Three-way movie HD",
+ PrimaryVersionId = _threeWayMovie,
+ Width = 1920,
+ Height = 1080
+ });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _threeWay4K,
+ Type = MovieType,
+ Name = "Three-way movie 4K",
+ PrimaryVersionId = _threeWayMovie,
+ Width = 3840,
+ Height = 2160
+ });
+
+ context.BaseItems.Add(new BaseItemEntity { Id = _hdOnlyByVersion, Type = MovieType, Name = "HD only by version" });
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _hdOnlyAlternate,
+ Type = MovieType,
+ Name = "HD only by version, HD file",
+ PrimaryVersionId = _hdOnlyByVersion,
+ Width = 1920,
+ Height = 1080
+ });
+
+ foreach (var itemId in new[] { _threeWayMovie, _threeWayHd, _threeWay4K, _hdOnlyByVersion, _hdOnlyAlternate })
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = _versionLibrary,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
new file mode 100644
index 0000000000..f2ecfadd50
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs
@@ -0,0 +1,526 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.MatchCriteria;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that
+/// they stay sub-selects instead of inlining every descendant id into the statement.
+/// </summary>
+public sealed class DescendantQueryHelperTests : SqliteDbTestFixture
+{
+ private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
+ private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+
+ private readonly Dictionary<Guid, int> _linkCounters = new();
+
+ public DescendantQueryHelperTests()
+ {
+ }
+
+ [Fact]
+ public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent()
+ {
+ var library = Guid.NewGuid();
+ var series = Guid.NewGuid();
+ var season = Guid.NewGuid();
+ var episode = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, library);
+ AddFolder(ctx, series);
+ AddFolder(ctx, season);
+ AddItem(ctx, episode, MovieType);
+
+ // AncestorIds is a closure: production writes one row per ancestor, not just the parent.
+ AddAncestors(ctx, series, library);
+ AddAncestors(ctx, season, series, library);
+ AddAncestors(ctx, episode, season, series, library);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
+
+ Assert.Equal(new[] { series, season, episode }.Order(), descendants.Order());
+ Assert.DoesNotContain(library, descendants);
+ }
+ }
+
+ [Fact]
+ public void GetAllDescendantIds_LinkedFolder_IncludesItsOwnDescendants()
+ {
+ var boxSet = Guid.NewGuid();
+ var series = Guid.NewGuid();
+ var episode = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddFolder(ctx, series);
+ AddItem(ctx, episode, MovieType);
+
+ AddAncestors(ctx, episode, series);
+ AddLink(ctx, boxSet, series);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet();
+
+ Assert.Contains(series, descendants);
+ Assert.Contains(episode, descendants);
+ }
+ }
+
+ // Timeout so that a missing termination guard fails the test instead of hanging the run.
+ [Fact(Timeout = 30000)]
+ public void GetAllDescendantIds_NestedLinks_AreFollowedAndCyclesTerminate()
+ {
+ var outer = Guid.NewGuid();
+ var inner = Guid.NewGuid();
+ var movie = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddItem(ctx, outer, BoxSetType, isFolder: true);
+ AddItem(ctx, inner, BoxSetType, isFolder: true);
+ AddItem(ctx, movie, MovieType);
+
+ AddLink(ctx, outer, inner);
+ AddLink(ctx, inner, movie);
+ // The traversal must not spin on this cycle.
+ AddLink(ctx, inner, outer);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, outer).ToHashSet();
+
+ Assert.Contains(inner, descendants);
+ Assert.Contains(movie, descendants);
+ Assert.DoesNotContain(outer, descendants);
+ }
+ }
+
+ [Fact]
+ public void GetAllDescendantIds_LinksOfNonFolders_AreNotFollowed()
+ {
+ var library = Guid.NewGuid();
+ var movie = Guid.NewGuid();
+ var alternateVersion = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, library);
+ AddItem(ctx, movie, MovieType);
+ AddItem(ctx, alternateVersion, MovieType);
+
+ AddAncestors(ctx, movie, library);
+ // An alternate version hangs off the movie by link, and the movie is not a folder.
+ AddLink(ctx, movie, alternateVersion);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
+
+ Assert.Contains(movie, descendants);
+ Assert.DoesNotContain(alternateVersion, descendants);
+ }
+ }
+
+ [Fact]
+ public void GetAllDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
+ {
+ var userRoot = Guid.NewGuid();
+ var collectionFolder = Guid.NewGuid();
+ var series = Guid.NewGuid();
+ var episode = Guid.NewGuid();
+ var boxSet = Guid.NewGuid();
+ var linkedMovie = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, userRoot);
+ AddFolder(ctx, collectionFolder);
+ AddFolder(ctx, series);
+ AddItem(ctx, episode, MovieType);
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddItem(ctx, linkedMovie, MovieType);
+
+ // An item carries its own chain plus its collection folder, but not the user root above
+ // it, so one hop from the user root stops at the collection folder.
+ AddAncestors(ctx, collectionFolder, userRoot);
+ AddAncestors(ctx, series, collectionFolder);
+ AddAncestors(ctx, episode, series, collectionFolder);
+ AddAncestors(ctx, boxSet, collectionFolder);
+ // The box set is only reachable across the seam, and its links have to be followed too.
+ AddLink(ctx, boxSet, linkedMovie);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, userRoot).ToHashSet();
+
+ Assert.Equal(
+ new[] { collectionFolder, series, episode, boxSet, linkedMovie }.Order(),
+ descendants.Order());
+ }
+ }
+
+ [Fact]
+ public void GetOwnedDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
+ {
+ var userRoot = Guid.NewGuid();
+ var collectionFolder = Guid.NewGuid();
+ var series = Guid.NewGuid();
+ var episode = Guid.NewGuid();
+ var boxSet = Guid.NewGuid();
+ var linkedMovie = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, userRoot);
+ AddFolder(ctx, collectionFolder);
+ AddFolder(ctx, series);
+ AddItem(ctx, episode, MovieType);
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddItem(ctx, linkedMovie, MovieType);
+
+ AddAncestors(ctx, collectionFolder, userRoot);
+ AddAncestors(ctx, series, collectionFolder);
+ AddAncestors(ctx, episode, series, collectionFolder);
+ AddAncestors(ctx, boxSet, collectionFolder);
+ AddLink(ctx, boxSet, linkedMovie);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ // Owned only: the linked movie stays out, or deleting a library would delete it.
+ var expected = new[] { collectionFolder, series, episode, boxSet }.Order();
+
+ Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIds(ctx, userRoot).ToHashSet().Order());
+ Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [userRoot]).Order());
+ }
+ }
+
+ [Fact]
+ public void GetFolderIdsMatching_LinkAboveAClosure_ReturnsTheLinkingFolder()
+ {
+ var collections = Guid.NewGuid();
+ var boxSet = Guid.NewGuid();
+ var library = Guid.NewGuid();
+ var series = Guid.NewGuid();
+ var episode = Guid.NewGuid();
+ var otherLibrary = Guid.NewGuid();
+ var otherBoxSet = Guid.NewGuid();
+ var silentMovie = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, collections);
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddFolder(ctx, library);
+ AddFolder(ctx, series);
+ AddItem(ctx, episode, MovieType);
+
+ AddAncestors(ctx, boxSet, collections);
+ AddAncestors(ctx, series, library);
+ AddAncestors(ctx, episode, series, library);
+ // The link lands on the series, not on the episode that carries the subtitles.
+ AddLink(ctx, boxSet, series);
+ AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
+
+ AddFolder(ctx, otherLibrary);
+ AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true);
+ AddItem(ctx, silentMovie, MovieType);
+ AddAncestors(ctx, otherBoxSet, collections);
+ AddAncestors(ctx, silentMovie, otherLibrary);
+ AddLink(ctx, otherBoxSet, silentMovie);
+ // A stream of another type: the criteria, not the mere presence of a stream, decides.
+ AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
+
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
+
+ Assert.Equal(new[] { library, series, boxSet, collections }.Order(), folders.Order());
+ }
+ }
+
+ [Fact(Timeout = 30000)]
+ public void GetFolderIdsMatching_NestedLinks_AreFollowedAndCyclesTerminate()
+ {
+ var outer = Guid.NewGuid();
+ var inner = Guid.NewGuid();
+ var movie = Guid.NewGuid();
+ var silentSet = Guid.NewGuid();
+ var silentMovie = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddItem(ctx, outer, BoxSetType, isFolder: true);
+ AddItem(ctx, inner, BoxSetType, isFolder: true);
+ AddItem(ctx, movie, MovieType);
+
+ AddLink(ctx, outer, inner);
+ AddLink(ctx, inner, movie);
+ // Resolving the link parents must not spin on this cycle.
+ AddLink(ctx, inner, outer);
+ AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle);
+
+ AddItem(ctx, silentSet, BoxSetType, isFolder: true);
+ AddItem(ctx, silentMovie, MovieType);
+ AddLink(ctx, silentSet, silentMovie);
+ AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
+
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
+
+ Assert.Equal(new[] { inner, outer }.Order(), folders.Order());
+ }
+ }
+
+ [Fact]
+ public void GetFolderIdsMatching_ClosureSeamAboveTheCollectionFolder_IsCrossed()
+ {
+ var userRoot = Guid.NewGuid();
+ var collectionFolder = Guid.NewGuid();
+ var series = Guid.NewGuid();
+ var episode = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, userRoot);
+ AddFolder(ctx, collectionFolder);
+ AddFolder(ctx, series);
+ AddItem(ctx, episode, MovieType);
+
+ // The closure is not transitive at this seam: no item records the user root.
+ AddAncestors(ctx, episode, series, collectionFolder);
+ AddAncestors(ctx, series, collectionFolder);
+ AddAncestors(ctx, collectionFolder, userRoot);
+ AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
+
+ Assert.Equal(new[] { series, collectionFolder, userRoot }.Order(), folders.Order());
+ }
+ }
+
+ [Fact]
+ public void GetFolderIdsMatching_LinkedFolder_MatchesOnLanguageOnly()
+ {
+ var boxSet = Guid.NewGuid();
+ var series = Guid.NewGuid();
+ var episode = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddFolder(ctx, series);
+ AddItem(ctx, episode, MovieType);
+
+ AddAncestors(ctx, episode, series);
+ AddLink(ctx, boxSet, series);
+ AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle, "ger");
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var german = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["ger"]);
+ var french = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["fre"]);
+
+ Assert.Equal(new[] { series, boxSet }.Order(), DescendantQueryHelper.GetFolderIdsMatching(ctx, german).ToHashSet().Order());
+ Assert.Empty(DescendantQueryHelper.GetFolderIdsMatching(ctx, french).ToArray());
+ }
+ }
+
+ [Fact]
+ public void GetFolderIdsMatching_AlternateVersionLinks_AreNotWalked()
+ {
+ var collections = Guid.NewGuid();
+ var boxSet = Guid.NewGuid();
+ var library = Guid.NewGuid();
+ var movie = Guid.NewGuid();
+ var alternateVersion = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddFolder(ctx, collections);
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddFolder(ctx, library);
+ AddItem(ctx, movie, MovieType);
+ AddItem(ctx, alternateVersion, MovieType);
+
+ AddAncestors(ctx, boxSet, collections);
+ AddAncestors(ctx, movie, library);
+ AddAncestors(ctx, alternateVersion, library);
+ // Only the second file carries the subtitles, and it hangs off the movie by an alternate
+ // version link. The movie is not a folder, so that link is not a parent-child edge.
+ AddLink(ctx, movie, alternateVersion, LinkedChildType.LocalAlternateVersion);
+ AddLink(ctx, boxSet, movie);
+ AddStream(ctx, alternateVersion, MediaStreamTypeEntity.Subtitle);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
+
+ // The library still matches: the alternate version carries its own closure. The box set does
+ // not, matching the descendant side, which does not follow a non-folder's links either.
+ Assert.Equal([library], folders);
+ }
+ }
+
+ [Fact]
+ public void GetOwnedDescendantIds_IgnoresLinkedChildren()
+ {
+ var boxSet = Guid.NewGuid();
+ var owned = Guid.NewGuid();
+ var linked = Guid.NewGuid();
+
+ using (var ctx = CreateDbContext())
+ {
+ AddItem(ctx, boxSet, BoxSetType, isFolder: true);
+ AddItem(ctx, owned, MovieType);
+ AddItem(ctx, linked, MovieType);
+
+ AddAncestors(ctx, owned, boxSet);
+ AddLink(ctx, boxSet, linked);
+ ctx.SaveChanges();
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIds(ctx, boxSet).ToArray());
+ Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [boxSet]).ToArray());
+ }
+ }
+
+ [Fact]
+ public void GetAllDescendantIds_StatementSizeDoesNotGrowWithTheLibrary()
+ {
+ var small = SeedLibrary(10);
+ var large = SeedLibrary(500);
+
+ using var ctx = CreateDbContext();
+
+ var smallSql = CountingQuery(ctx, small).ToQueryString();
+ var largeSql = CountingQuery(ctx, large).ToQueryString();
+
+ // Reading the ids into memory and handing them back as AsQueryable() makes EF inline one
+ // literal per descendant, which is what allocated megabytes per call.
+ Assert.Equal(smallSql.Length, largeSql.Length);
+ Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal);
+ Assert.Equal(10, CountingQuery(ctx, small).Count());
+ Assert.Equal(500, CountingQuery(ctx, large).Count());
+ }
+
+ private static IQueryable<BaseItemEntity> CountingQuery(JellyfinDbContext context, Guid libraryId)
+ {
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, libraryId);
+
+ return context.BaseItems
+ .AsNoTracking()
+ .Where(b => descendantIds.Contains(b.Id))
+ .Where(DescendantQueryHelper.IsCountableLeaf);
+ }
+
+ private Guid SeedLibrary(int childCount)
+ {
+ var library = Guid.NewGuid();
+
+ using var ctx = CreateDbContext();
+ AddFolder(ctx, library);
+ for (var i = 0; i < childCount; i++)
+ {
+ var child = Guid.NewGuid();
+ AddItem(ctx, child, MovieType);
+ AddAncestors(ctx, child, library);
+ }
+
+ ctx.SaveChanges();
+
+ return library;
+ }
+
+ private static void AddFolder(JellyfinDbContext context, Guid id)
+ => AddItem(context, id, FolderType, isFolder: true);
+
+ private static void AddItem(JellyfinDbContext context, Guid id, string type, bool isFolder = false)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = type,
+ Name = type + " " + id,
+ IsFolder = isFolder
+ });
+
+ private static void AddStream(JellyfinDbContext context, Guid itemId, MediaStreamTypeEntity type, string? language = null)
+ => context.MediaStreamInfos.Add(new MediaStreamInfo
+ {
+ ItemId = itemId,
+ StreamIndex = 0,
+ StreamType = type,
+ Language = language,
+ Item = null!
+ });
+
+ private static void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
+ {
+ foreach (var ancestorId in ancestorIds)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = ancestorId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ // LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot.
+ private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId, LinkedChildType childType = LinkedChildType.Manual)
+ {
+ _linkCounters.TryGetValue(parentId, out var sortOrder);
+ _linkCounters[parentId] = sortOrder + 1;
+
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = parentId,
+ ChildId = childId,
+ ChildType = childType,
+ SortOrder = sortOrder
+ });
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
new file mode 100644
index 0000000000..947cf54d85
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -0,0 +1,287 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemCountServiceTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ItemCountService _service;
+
+ public ItemCountServiceTests()
+ {
+ _applicationPaths = new Mock<IApplicationPaths>().Object;
+
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var context = CreateDbContext())
+ {
+ context.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var queryHelpers = new Mock<IItemQueryHelpers>();
+ queryHelpers
+ .Setup(h => h.ApplyAccessFiltering(
+ It.IsAny<JellyfinDbContext>(),
+ It.IsAny<IQueryable<BaseItemEntity>>(),
+ It.IsAny<InternalItemsQuery>()))
+ .Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query);
+
+ _service = new ItemCountService(
+ factory.Object,
+ new Mock<IItemTypeLookup>().Object,
+ queryHelpers.Object);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit()
+ {
+ var hierarchicalParentId = Guid.NewGuid();
+ var linkedParentId = Guid.NewGuid();
+
+ var hierarchicalChildId = Guid.NewGuid();
+ var linkedChildId1 = Guid.NewGuid();
+ var linkedChildId2 = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ CreateItem(hierarchicalParentId),
+ CreateItem(linkedParentId),
+ CreateItem(hierarchicalChildId, hierarchicalParentId),
+ CreateItem(linkedChildId1),
+ CreateItem(linkedChildId2));
+
+ context.LinkedChildren.AddRange(
+ new LinkedChildEntity
+ {
+ ParentId = linkedParentId,
+ ChildId = linkedChildId1,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = linkedParentId,
+ ChildId = linkedChildId2,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 1
+ });
+
+ context.SaveChanges();
+ }
+
+ var parentIds = Enumerable.Range(0, 40_000)
+ .Select(_ => Guid.NewGuid())
+ .ToList();
+
+ parentIds.Add(hierarchicalParentId);
+ parentIds.Add(linkedParentId);
+
+ var result = _service.GetChildCountBatch(parentIds, null);
+
+ Assert.Equal(1, result[hierarchicalParentId]);
+ Assert.Equal(2, result[linkedParentId]);
+ Assert.Equal(parentIds.Count, result.Count);
+ }
+
+ [Fact]
+ public void GetCounts_MergedFolders_CountLeavesOfEveryFolderInTheGroup()
+ {
+ // Two folder-items of one merged series: same presentation key, a leaf each, one of them played.
+ var (user, seriesA, seriesB) = SeedMergedSeries(out var playedLeafId);
+
+ var filter = new InternalItemsQuery(user);
+
+ // Either folder-item stands for the whole merged series, so both must report the group.
+ foreach (var seriesId in new[] { seriesA, seriesB })
+ {
+ Assert.Equal(2, _service.GetTotalCount(filter, seriesId));
+ Assert.Equal(1, _service.GetPlayedCount(filter, seriesId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, seriesId));
+ }
+
+ var batch = _service.GetPlayedAndTotalCountBatch([seriesA], user);
+ Assert.Equal((1, 2), batch[seriesA]);
+
+ Assert.NotEqual(Guid.Empty, playedLeafId);
+ }
+
+ [Fact]
+ public void GetCounts_UnmergedFolder_CountsOnlyItsOwnLeaves()
+ {
+ var (user, _, _) = SeedMergedSeries(out _);
+
+ // A folder with a key of its own must not pick up anything from the merged pair.
+ var loneSeriesId = Guid.NewGuid();
+ var loneLeafId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ var lone = CreateItem(loneSeriesId);
+ lone.PresentationUniqueKey = "lone-series";
+ context.BaseItems.Add(lone);
+ context.BaseItems.Add(CreateLeaf(loneLeafId));
+ context.SaveChanges();
+ AddAncestor(context, loneLeafId, loneSeriesId);
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ Assert.Equal(1, _service.GetTotalCount(filter, loneSeriesId));
+ Assert.Equal(0, _service.GetPlayedCount(filter, loneSeriesId));
+ Assert.Equal((0, 1), _service.GetPlayedAndTotalCount(filter, loneSeriesId));
+ }
+
+ [Fact]
+ public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys()
+ {
+ var seriesA = Guid.NewGuid();
+ var seriesB = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ foreach (var id in new[] { seriesA, seriesB })
+ {
+ var series = CreateItem(id);
+ series.PresentationUniqueKey = "merged-series";
+ context.BaseItems.Add(series);
+ }
+
+ // Each folder-item holds a "Season 1"; those two share a key and are one season to the user.
+ var sharedSeasonA = CreateItem(Guid.NewGuid(), seriesA);
+ sharedSeasonA.PresentationUniqueKey = "merged-series-001";
+ var sharedSeasonB = CreateItem(Guid.NewGuid(), seriesB);
+ sharedSeasonB.PresentationUniqueKey = "merged-series-001";
+ var ownSeason = CreateItem(Guid.NewGuid(), seriesB);
+ ownSeason.PresentationUniqueKey = "merged-series-002";
+
+ context.BaseItems.AddRange(sharedSeasonA, sharedSeasonB, ownSeason);
+ context.SaveChanges();
+ }
+
+ var result = _service.GetChildCountBatch([seriesA, seriesB], null);
+
+ Assert.Equal(2, result[seriesA]);
+ Assert.Equal(2, result[seriesB]);
+ }
+
+ private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
+ {
+ var user = new User("count-test", "provider", "reset");
+ var seriesA = Guid.NewGuid();
+ var seriesB = Guid.NewGuid();
+ var leafA = Guid.NewGuid();
+ var leafB = Guid.NewGuid();
+ playedLeafId = leafA;
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ foreach (var id in new[] { seriesA, seriesB })
+ {
+ var series = CreateItem(id);
+ series.PresentationUniqueKey = "merged-series";
+ context.BaseItems.Add(series);
+ }
+
+ context.BaseItems.AddRange(CreateLeaf(leafA), CreateLeaf(leafB));
+ context.SaveChanges();
+
+ AddAncestor(context, leafA, seriesA);
+ AddAncestor(context, leafB, seriesB);
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = leafA,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ return (user, seriesA, seriesB);
+ }
+
+ private static void AddAncestor(JellyfinDbContext context, Guid itemId, Guid parentItemId)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = parentItemId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+
+ private static BaseItemEntity CreateLeaf(Guid id)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Episode",
+ IsFolder = false,
+ IsVirtualItem = false,
+ PresentationUniqueKey = id.ToString("N")
+ };
+ }
+
+ private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Folder",
+ ParentId = parentId,
+ IsFolder = true
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(
+ _applicationPaths,
+ NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
new file mode 100644
index 0000000000..82614c3156
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IServerConfigurationManager? _previousConfigurationManager;
+
+ public ItemPersistenceOwnedRowTests()
+ {
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousConfigurationManager = BaseItem.ConfigurationManager;
+
+ 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;
+
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ new Mock<IServerApplicationHost>().Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ base.Dispose(disposing);
+ }
+
+ [Fact]
+ public void SaveItems_UpdateExistingItem_ReplacesOwnedRows()
+ {
+ var id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+
+ _service.SaveItems(
+ [CreateBook(id, new() { ["Imdb"] = "tt0001", ["Tmdb"] = "555" }, [MetadataField.Name])],
+ CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ Assert.Equal(2, ctx.BaseItemProviders.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(1, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id)));
+ }
+
+ // Re-save with different owned rows: the update path rewrites all three tables wholesale.
+ _service.SaveItems(
+ [CreateBook(id, new() { ["Imdb"] = "tt9999" }, [MetadataField.Name, MetadataField.Genres])],
+ CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ var providers = ctx.BaseItemProviders.Where(e => e.ItemId.Equals(id)).ToList();
+ Assert.Equal("tt9999", Assert.Single(providers).ProviderValue);
+
+ Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(2, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id)));
+ }
+ }
+
+ [Fact]
+ public void SaveItems_MixedNewAndExistingBatch_ReplacesOnlyExistingOwnedRows()
+ {
+ var existing = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ var fresh = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+
+ _service.SaveItems([CreateBook(existing, new() { ["Imdb"] = "tt0001" }, [])], CancellationToken.None);
+
+ // One already-persisted item and one brand new item in the same batch.
+ _service.SaveItems(
+ [
+ CreateBook(existing, new() { ["Imdb"] = "tt0002" }, []),
+ CreateBook(fresh, new() { ["Tmdb"] = "777" }, [])
+ ],
+ CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ Assert.Equal("tt0002", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(existing))).ProviderValue);
+ Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue);
+ }
+
+ private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields)
+ {
+ var book = new Book
+ {
+ Id = id,
+ Name = "Book",
+ ProviderIds = providerIds,
+ LockedFields = lockedFields
+ };
+
+ book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
+ return book;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
new file mode 100644
index 0000000000..54565c5787
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture
+{
+ private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+
+ private readonly PeopleRepository _repository;
+
+ public PeopleRepositoryUpdatePeopleTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _itemId,
+ Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie],
+ Name = "Movie",
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ });
+ ctx.SaveChanges();
+ }
+
+ _repository = new PeopleRepository(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ new Mock<IItemQueryHelpers>().Object);
+ }
+
+ [Fact]
+ public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel"),
+ CreatePerson("Person A", PersonKind.Writer, "Screenplay")
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Single(ctx.Peoples);
+ Assert.Equal(
+ ["Novel", "Screenplay"],
+ ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
+ }
+
+ [Fact]
+ public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("person a", PersonKind.Actor, "hero")
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Single(ctx.Peoples);
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Hero", map.Role);
+ }
+
+ [Fact]
+ public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("Person A", PersonKind.Director, string.Empty)
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Equal(2, ctx.Peoples.Count());
+ Assert.Equal(2, ctx.PeopleBaseItemMap.Count());
+ }
+
+ [Fact]
+ public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("Person B", PersonKind.Actor, "Sidekick")
+ ]);
+
+ Guid[] peopleIdsBefore;
+ using (var ctx = CreateDbContext())
+ {
+ peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray();
+ }
+
+ // Reversed order, so the list order of both mappings has to be rewritten.
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person B", PersonKind.Actor, "Sidekick"),
+ CreatePerson("Person A", PersonKind.Actor, "Hero")
+ ]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray());
+ Assert.Equal(
+ ["Sidekick", "Hero"],
+ after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
+ }
+
+ [Fact]
+ public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel"),
+ CreatePerson("Person A", PersonKind.Writer, "Screenplay")
+ ]);
+
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel")
+ ]);
+
+ using var ctx = CreateDbContext();
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Novel", map.Role);
+ }
+
+ [Fact]
+ public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping()
+ {
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]);
+
+ using var ctx = CreateDbContext();
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Hero", map.Role);
+ }
+
+ private static PersonInfo CreatePerson(string name, PersonKind type, string role)
+ {
+ return new PersonInfo
+ {
+ Name = name,
+ Type = type,
+ Role = role
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
new file mode 100644
index 0000000000..87efa8fea5
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -0,0 +1,85 @@
+using System;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Base fixture for the item tests that run against the SQLite provider: one in-memory database per
+/// test class, plus the wiring the repositories under test need. The connection owns the database, so
+/// it stays open for the lifetime of the fixture. Derived classes seed in their own constructor.
+/// </summary>
+public abstract class SqliteDbTestFixture : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+
+ protected SqliteDbTestFixture()
+ {
+ ApplicationPaths = new Mock<IApplicationPaths>().Object;
+
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ protected IApplicationPaths ApplicationPaths { get; }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+
+ protected IDbContextFactory<JellyfinDbContext> CreateDbContextFactory()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ return factory.Object;
+ }
+
+ protected BaseItemRepository CreateBaseItemRepository(ItemTypeLookup itemTypeLookup)
+ {
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+
+ return new BaseItemRepository(
+ CreateDbContextFactory(),
+ new Mock<IServerApplicationHost>().Object,
+ itemTypeLookup,
+ serverConfigurationManager.Object,
+ NullLogger<BaseItemRepository>.Instance);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _connection.Dispose();
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
index 958ffb8b6e..29de52a2ba 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
+++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
@@ -4,6 +4,7 @@
<PropertyGroup>
<ProjectGuid>{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}</ProjectGuid>
<OutputType>Exe</OutputType>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
index 562711337f..a28c1d6dfb 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.Json;
using AutoFixture;
using AutoFixture.AutoMoq;
using Emby.Naming.Common;
@@ -17,6 +18,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using Moq;
using Xunit;
@@ -38,9 +40,15 @@ public class FindExtrasTests
itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
_fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
_fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
+
+ var strings = LoadCoreStrings();
+ fixture.Freeze<Mock<ILocalizationManager>>()
+ .Setup(l => l.GetServerLocalizedString(It.IsAny<string>()))
+ .Returns<string>(key => strings.TryGetValue(key, out var value) ? value : key);
+
_libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts(
fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
- new List<IItemResolver> { new AudioResolver(fixture.Create<NamingOptions>()) },
+ [new AudioResolver(fixture.Create<NamingOptions>())],
fixture.Create<IEnumerable<IIntroProvider>>(),
fixture.Create<IEnumerable<IBaseItemComparer>>(),
fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
@@ -51,6 +59,16 @@ public class FindExtrasTests
BaseItem.MediaSourceManager ??= fixture.Create<IMediaSourceManager>();
}
+ private static Dictionary<string, string> LoadCoreStrings()
+ {
+ using var stream = typeof(Emby.Server.Implementations.Library.LibraryManager).Assembly
+ .GetManifestResourceStream("Emby.Server.Implementations.Localization.Core.en-US.json")
+ ?? throw new InvalidOperationException("Core localization resource is missing");
+
+ return JsonSerializer.Deserialize<Dictionary<string, string>>(stream)
+ ?? throw new InvalidOperationException("Core localization resource is empty");
+ }
+
[Fact]
public void FindExtras_SeparateMovieFolder_FindsCorrectExtras()
{
@@ -132,60 +150,60 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/trailers/some trailer.mkv",
Name = "some trailer.mkv",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/behind the scenes",
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/behind the scenes/the making of Up.mkv",
Name = "the making of Up.mkv",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/theme-music",
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/theme-music/theme2.mp3",
Name = "theme2.mp3",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/extras",
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/extras/Honest Trailer.mkv",
Name = "Honest Trailer.mkv",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
var files = paths.Select(p => new FileSystemMetadata
{
@@ -289,15 +307,15 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/trailers/trailer.jpg",
Name = "trailer.jpg",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
@@ -306,6 +324,47 @@ public class FindExtrasTests
}
[Fact]
+ public void FindExtras_TrailerWithYearInFilename_SetsProductionYearFromFilename()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up/Up.mkv",
+ "/movies/Up/trailers"
+ };
+
+ _fileSystemMock.Setup(f => f.GetFiles(
+ "/movies/Up/trailers",
+ It.IsAny<string[]>(),
+ false,
+ false))
+ .Returns(
+ [
+ new()
+ {
+ FullName = "/movies/Up/trailers/Trailer 1 (2013).mkv",
+ Name = "Trailer 1 (2013).mkv",
+ IsDirectory = false
+ }
+ ]).Verifiable();
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ IsDirectory = !Path.HasExtension(p)
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).ToList();
+
+ _fileSystemMock.Verify();
+ var trailer = Assert.Single(extras);
+ Assert.Equal(ExtraType.Trailer, trailer.ExtraType);
+ Assert.Equal(typeof(Trailer), trailer.GetType());
+ Assert.Equal(2013, trailer.ProductionYear);
+ }
+
+ [Fact]
public void FindExtras_SeriesWithTrailers_FindsCorrectExtras()
{
var owner = new Series { Name = "Dexter", Path = "/series/Dexter" };
@@ -331,4 +390,198 @@ public class FindExtrasTests
Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path);
Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path);
}
+
+ [Fact]
+ public void FindExtras_SameExtraInSeveralContainers_ReturnsEach()
+ {
+ var owner = new Movie { Name = "Skyscraper", Path = "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ // A container is a separate file that plays on its own, so it is a separate extra
+ Assert.Equal(4, extras.Count);
+ Assert.Equal("Behind The Scenes", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv"]);
+ Assert.Equal("Behind The Scenes 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"]);
+ Assert.Equal("Trailer", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv"]);
+ Assert.Equal("Trailer 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4"]);
+ }
+
+ [Fact]
+ public void FindExtras_SameExtraInSeveralResolutions_ReturnsEach()
+ {
+ var owner = new Movie { Name = "Dragon 2", Path = "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv",
+ "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv",
+ "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ Assert.Equal(2, extras.Count);
+ Assert.Equal("Trailer", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv"]);
+ Assert.Equal("Trailer 2", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"]);
+ }
+
+ [Fact]
+ public void FindExtras_NumberedExtras_AreKeptApart()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer.mkv",
+ "/movies/Up (2009)/Up (2009)-trailer2.mkv",
+ "/movies/Up (2009)/Up (2009)-trailer2.mp4",
+ "/movies/Up (2009)/Up (2009)-trailer3.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
+
+ Assert.Equal(4, extras.Count);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer.mkv", extras[0].Path);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mkv", extras[1].Path);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mp4", extras[2].Path);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer3.mkv", extras[3].Path);
+
+ // The index in the file name is not the number the extra is given, which counts the
+ // extras of a type as they are found
+ Assert.Equal("Trailer", extras[0].Name);
+ Assert.Equal("Trailer 2", extras[1].Name);
+ Assert.Equal("Trailer 3", extras[2].Name);
+ Assert.Equal("Trailer 4", extras[3].Name);
+ }
+
+ [Fact]
+ public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer.mkv",
+ "/movies/Up (2009)/Recording the audio-behindthescenes.mkv",
+ "/movies/Up (2009)/Up (2009)-behindthescenes.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ Assert.Equal(3, extras.Count);
+ Assert.Equal("Trailer", extras["/movies/Up (2009)/Up (2009)-trailer.mkv"]);
+
+ // A descriptive file name is a real title and survives, and does not consume a number
+ Assert.Equal("Recording the audio", extras["/movies/Up (2009)/Recording the audio-behindthescenes.mkv"]);
+ Assert.Equal("Behind The Scenes", extras["/movies/Up (2009)/Up (2009)-behindthescenes.mkv"]);
+ }
+
+ [Fact]
+ public void FindExtras_ExtraInOwnFolder_IsNamedAfterItsFile()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up/Up.mkv",
+ "/movies/Up/trailers"
+ };
+
+ _fileSystemMock.Setup(f => f.GetFiles(
+ "/movies/Up/trailers",
+ It.IsAny<string[]>(),
+ false,
+ false))
+ .Returns(
+ [
+ new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false },
+ new() { FullName = "/movies/Up/trailers/Comic-Con Reel.mkv", Name = "Comic-Con Reel.mkv", IsDirectory = false }
+ ]).Verifiable();
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ IsDirectory = !Path.HasExtension(p)
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ _fileSystemMock.Verify();
+ Assert.Equal(2, extras.Count);
+ Assert.Equal("Teaser", extras["/movies/Up/trailers/Teaser.mkv"]);
+ Assert.Equal("Comic-Con Reel", extras["/movies/Up/trailers/Comic-Con Reel.mkv"]);
+ }
+
+ [Fact]
+ public void FindExtras_DistinctExtrasInSameFolder_AreKeptApart()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up/Up.mkv",
+ "/movies/Up/trailers"
+ };
+
+ _fileSystemMock.Setup(f => f.GetFiles(
+ "/movies/Up/trailers",
+ It.IsAny<string[]>(),
+ false,
+ false))
+ .Returns(
+ [
+ new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false },
+ new() { FullName = "/movies/Up/trailers/Official.mkv", Name = "Official.mkv", IsDirectory = false },
+ new() { FullName = "/movies/Up/trailers/Official.mp4", Name = "Official.mp4", IsDirectory = false }
+ ]).Verifiable();
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ IsDirectory = !Path.HasExtension(p)
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
+
+ _fileSystemMock.Verify();
+ Assert.Equal(3, extras.Count);
+ Assert.Equal("/movies/Up/trailers/Official.mkv", extras[0].Path);
+ Assert.Equal("/movies/Up/trailers/Official.mp4", extras[1].Path);
+ Assert.Equal("/movies/Up/trailers/Teaser.mkv", extras[2].Path);
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
new file mode 100644
index 0000000000..65ec41291d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library;
+using Emby.Server.Implementations.Sorting;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Enums;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+using BaseItem = MediaBrowser.Controller.Entities.BaseItem;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class LibraryManagerSortTests
+{
+ [Fact]
+ public void Sort_UserDependentKey_NullUser_ThrowsArgumentException()
+ {
+ var libraryManager = CreateLibraryManager(
+ new IBaseItemComparer[] { new PlayCountComparer(), new SortNameComparer() });
+
+ BaseItem[] items =
+ {
+ new Audio { Name = "Zulu", SortName = "Zulu", Id = Guid.NewGuid() },
+ new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() },
+ };
+
+ Assert.Throws<ArgumentException>(() => libraryManager.Sort(
+ items,
+ user: null,
+ new[] { (ItemSortBy.PlayCount, SortOrder.Descending) }).ToArray());
+ }
+
+ [Fact]
+ public void Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName()
+ {
+ var libraryManager = CreateLibraryManager(
+ new IBaseItemComparer[] { new DateLastMediaAddedComparer(), new SortNameComparer() });
+
+ BaseItem[] items =
+ {
+ MakeFolder("Alpha", new DateTime(2026, 1, 1)),
+ MakeFolder("Mike", new DateTime(2025, 1, 1)),
+ MakeFolder("Zulu", new DateTime(2024, 1, 1))
+ };
+
+ var sorted = libraryManager.Sort(
+ items,
+ user: null,
+ new[] { (ItemSortBy.DateLastContentAdded, SortOrder.Descending) }).ToArray();
+
+ Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name));
+ }
+
+ private static Folder MakeFolder(string name, DateTime dateLastMediaAdded)
+ => new() { Name = name, Id = Guid.NewGuid(), DateLastMediaAdded = dateLastMediaAdded };
+
+ private static Emby.Server.Implementations.Library.LibraryManager CreateLibraryManager(IReadOnlyCollection<IBaseItemComparer> comparers)
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ BaseItem.ConfigurationManager ??= configMock.Object;
+ var itemRepository = fixture.Freeze<Mock<IItemRepository>>();
+ itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
+ var fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
+ fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
+
+ return fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts(
+ fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
+ fixture.Create<IEnumerable<IItemResolver>>(),
+ fixture.Create<IEnumerable<IIntroProvider>>(),
+ comparers,
+ fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
+ .Create();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index facdb2bc2e..c80f899498 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using AutoFixture;
using AutoFixture.AutoMoq;
using Castle.Components.DictionaryAdapter;
@@ -7,6 +9,8 @@ using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -144,5 +148,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
_mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
}
+
+ [Fact]
+ public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary<Guid, UserItemData>
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ // The most recently played version is the default source, so resuming plays the right file.
+ // Per-user positions live in each version's UserData, not on the source.
+ Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary<Guid, UserItemData>
+ {
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
+
+ // An explicitly opened version keeps its own source first, even when a sibling was
+ // played more recently.
+ Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
+ Assert.Equal(3, sources.Count);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+ SetupUserDataBatch([]);
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoUser_DoesNotTouchUserData()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never);
+ }
+
+ private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData)
+ {
+ _mockUserDataManager
+ .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()))
+ .Returns((IReadOnlyList<BaseItem> items, User _) => items
+ .Where(i => userData.ContainsKey(i.Id))
+ .ToDictionary(i => i.Id, i => userData[i.Id]));
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // BaseItem.GetMediaSources runs against the static service locators.
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>());
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
+
+ var segmentManager = new Mock<IMediaSegmentManager>();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+
+ var recordingsManager = new Mock<IRecordingsManager>();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
+
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+ Video.RecordingsManager = recordingsManager.Object;
+
+ return (primary, alt1, alt2);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index 650d67b195..e65bc1d31f 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
[Theory]
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son", "imdbid", null)]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
[InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
[InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
[InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
+ [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")]
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
- [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]", "tmdbid", "618355")]
[InlineData("{tmdbid=618355}", "tmdbid", "618355")]
+ [InlineData("{tmdb=618355}", "tmdbid", "618355")]
[InlineData("(tmdbid=618355)", "tmdbid", "618355")]
+ [InlineData("(tmdb=618355)", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb-618355]", "tmdbid", "618355")]
[InlineData("{tmdbid-618355)", "tmdbid", null)]
+ [InlineData("{tmdb-618355)", "tmdbid", null)]
[InlineData("[tmdbid-618355}", "tmdbid", null)]
+ [InlineData("[tmdb-618355}", "tmdbid", null)]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("tmdbid=618355]", "tmdbid", null)]
+ [InlineData("tmdb=618355]", "tmdbid", null)]
[InlineData("[tmdbid=618355", "tmdbid", null)]
+ [InlineData("[tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=618355", "tmdbid", null)]
+ [InlineData("tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=", "tmdbid", null)]
+ [InlineData("tmdb=", "tmdbid", null)]
[InlineData("tmdbid", "tmdbid", null)]
+ [InlineData("tmdb", "tmdbid", null)]
+ [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")]
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")]
[InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
+ [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)]
[InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
+ [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)]
[InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")]
public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult)
{
Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute));
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
new file mode 100644
index 0000000000..ba3127bc08
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public sealed class UserDataManagerTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserDataManager _userDataManager;
+ private readonly User _user;
+
+ public UserDataManagerTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _userDataManager = new UserDataManager(config.Object, factory.Object);
+ _user = new User("user", "auth-provider", "reset-provider")
+ {
+ Id = Guid.NewGuid()
+ };
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ private AudioBook CreateAudioBook()
+ {
+ // GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"]
+ return new AudioBook
+ {
+ Id = Guid.NewGuid(),
+ Name = "Book Title",
+ Album = "Series",
+ AlbumArtists = new[] { "Author" },
+ IndexNumber = 1
+ };
+ }
+
+ private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
+ {
+ return new UserData
+ {
+ ItemId = item.Id,
+ Item = null,
+ UserId = _user.Id,
+ User = null,
+ CustomDataKey = key,
+ PlaybackPositionTicks = positionTicks
+ };
+ }
+
+ [Fact]
+ public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ // the retired-key row comes first to ensure selection is by key, not row order
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(currentKey, userData.Key);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var idKey = item.GetUserDataKeys()[1];
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, idKey, 333)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(idKey, userData.Key);
+ Assert.Equal(333, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow()
+ {
+ var item = CreateAudioBook();
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(111, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
+ {
+ var item = CreateAudioBook();
+ item.UserData = new List<UserData>();
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(item.GetUserDataKeys()[0], userData.Key);
+ Assert.Equal(0, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_RowsForOtherUsers_AreIgnored()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ var otherUserRow = CreateUserDataRow(item, currentKey, 999);
+ otherUserRow.UserId = Guid.NewGuid();
+
+ item.UserData = new List<UserData>
+ {
+ otherUserRow,
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
+ {
+ // no preloaded navigation data, so the batch takes the database fallback
+ var fossilItem = CreateAudioBook();
+ var retiredItem = CreateAudioBook();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(_user);
+ ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
+ ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
+
+ // the stale id-key row is inserted first so selection by row order would return it
+ ctx.UserData.AddRange(
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
+ CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
+ ctx.SaveChanges();
+ }
+
+ var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
+
+ Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
+ Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NullUser_ThrowsArgumentNullException()
+ {
+ var item = CreateAudioBook();
+ Assert.Throws<ArgumentNullException>(() => _userDataManager.GetUserData(null!, item));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 3b8fe5ca60..d973076ed3 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -119,6 +119,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Equal(code, culture.ThreeLetterISOLanguageName);
}
+ [Theory]
+ [InlineData("ell", "Greek")] // Comma truncation
+ [InlineData("nld", "Dutch")] // Semicolon truncation
+ [InlineData("ron", "Romanian")] // Semicolon truncation, multiple
+ [InlineData("eng", "English")] // No truncation
+ [InlineData("zh-CN", "Chinese (Simplified)")] // No truncation, with parentheses
+ public async Task GetLanguageDisplayName_DelimitedName_ReturnsTruncatedName(string language, string expected)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+ await localizationManager.LoadAll();
+
+ var result = localizationManager.GetLanguageDisplayName(language);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("xyz")]
+ public async Task GetLanguageDisplayName_InvalidInput_ReturnsNull(string? language)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+ await localizationManager.LoadAll();
+
+ var result = localizationManager.GetLanguageDisplayName(language!);
+ Assert.Null(result);
+ }
+
[Fact]
public async Task GetParentalRatings_Default_Success()
{
@@ -315,7 +349,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
});
var translated = localizationManager.GetLocalizedString("Artists", "de");
- Assert.Equal("Interpreten", translated);
+ Assert.Equal("Künstler", translated);
}
[Fact]
@@ -345,6 +379,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Fact]
+ public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+
+ // he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive,
+ // so the region casing has to be preserved or the file is not found and we fall back to en-US.
+ var translated = localizationManager.GetLocalizedString("Books", "he-IL");
+ Assert.Equal("ספרים", translated);
+ }
+
+ [Fact]
public void GetServerLocalizedString_UsesServerCulture()
{
var localizationManager = Setup(new ServerConfiguration
@@ -358,7 +406,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr");
var translated = localizationManager.GetServerLocalizedString("Artists");
- Assert.Equal("Interpreten", translated);
+ Assert.Equal("Künstler", translated);
}
finally
{
@@ -379,7 +427,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
var translated = localizationManager.GetLocalizedString("Artists");
- Assert.Equal("Interpreten", translated);
+ Assert.Equal("Künstler", translated);
}
finally
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
index ede9e61536..265b6a7f43 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
@@ -293,7 +293,84 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
Assert.Equal(packageInfo.Versions[0].Version, result.Version);
}
- private PackageInfo GenerateTestPackage()
+ [Fact]
+ public async Task DisablePlugin_CatalogRefresh_StaysDisabled()
+ {
+ var pluginRoot = Path.Combine(_tempPath, "plugins");
+ var pluginDir = CreateTestPlugin(pluginRoot, "Disable Me", PluginStatus.Active);
+
+ var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0));
+ var plugin = Assert.Single(pluginManager.Plugins);
+
+ pluginManager.DisablePlugin(plugin);
+
+ Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status);
+
+ // The web shows that a restart is required, but the persisted state must not change.
+ Assert.Equal(PluginStatus.Restart, plugin.GetPluginInfo().Status);
+ Assert.Equal(PluginStatus.Disabled, plugin.Manifest.Status);
+ Assert.True(plugin.Manifest.AutoUpdate);
+
+ // Every catalog fetch rewrites the manifests of installed plugins from the in-memory status.
+ var packageInfo = GenerateTestPackage(plugin.Id);
+ await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), pluginDir, plugin.Manifest.Status);
+
+ Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status);
+ }
+
+ [Fact]
+ public void Constructor_DisabledPluginSortingBeforeEnabledPlugin_IsNotDeleted()
+ {
+ var pluginRoot = Path.Combine(_tempPath, "plugins");
+ var disabledDir = CreateTestPlugin(pluginRoot, "AAA Disabled", PluginStatus.Disabled);
+ CreateTestPlugin(pluginRoot, "ZZZ Active", PluginStatus.Active);
+
+ var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0));
+
+ Assert.True(Directory.Exists(disabledDir));
+ Assert.Contains(pluginManager.Plugins, p => string.Equals(p.Name, "AAA Disabled", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void LoadAssemblies_DisabledPluginWithSupersededVersion_DoesNotRevertToOldVersion()
+ {
+ var pluginRoot = Path.Combine(_tempPath, "plugins");
+ var id = Guid.NewGuid();
+ var oldDir = CreateTestPlugin(pluginRoot, "Two Versions", PluginStatus.Superseded, new Version(1, 0), id);
+ var newDir = CreateTestPlugin(pluginRoot, "Two Versions_2.0", PluginStatus.Disabled, new Version(2, 0), id, "Two Versions");
+
+ var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0));
+
+ Assert.Empty(pluginManager.LoadAssemblies());
+
+ // Neither version may be touched: the old one stays superseded instead of being loaded
+ // as a stand-in for the version the user disabled.
+ Assert.Equal(PluginStatus.Superseded, pluginManager.LoadManifest(oldDir).Manifest.Status);
+ Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(newDir).Manifest.Status);
+ }
+
+ private string CreateTestPlugin(string root, string folderName, PluginStatus status, Version? version = null, Guid? id = null, string? name = null)
+ {
+ var dir = Path.Combine(root, folderName);
+ Directory.CreateDirectory(dir);
+ FileHelper.CreateEmpty(Path.Combine(dir, "some.dll"));
+
+ var manifest = new PluginManifest
+ {
+ Id = id ?? Guid.NewGuid(),
+ Name = name ?? folderName,
+ Status = status,
+ AutoUpdate = true,
+ TargetAbi = "1.0",
+ Version = (version ?? new Version(1, 0)).ToString()
+ };
+
+ File.WriteAllText(Path.Combine(dir, "meta.json"), JsonSerializer.Serialize(manifest, _options));
+
+ return dir;
+ }
+
+ private PackageInfo GenerateTestPackage(Guid? id = null)
{
var fixture = new Fixture();
fixture.Customize<PackageInfo>(c => c.Without(x => x.Versions).Without(x => x.ImageUrl));
@@ -305,6 +382,10 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
var packageInfo = fixture.Create<PackageInfo>();
packageInfo.Versions = new[] { versionInfo };
+ if (id.HasValue)
+ {
+ packageInfo.Id = id.Value;
+ }
return packageInfo;
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs
new file mode 100644
index 0000000000..7722707cbe
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Reflection;
+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.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+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 IdlePlaybackTests
+{
+ [Theory]
+ [InlineData(null, null)]
+ [InlineData(123456789L, 123456789L)]
+ public async Task CheckForIdlePlayback_StopsAtLastClientReportedPosition(long? clientPositionTicks, long? expectedPositionTicks)
+ {
+ var playbackStopped = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var eventManager = new Mock<IEventManager>();
+ eventManager
+ .Setup(manager => manager.PublishAsync(It.IsAny<PlaybackStopEventArgs>()))
+ .Callback<PlaybackStopEventArgs>(eventArgs => playbackStopped.TrySetResult(eventArgs.PlaybackPositionTicks))
+ .Returns(Task.CompletedTask);
+ await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ eventManager.Object,
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILibraryManager>(),
+ 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(
+ "Test Client",
+ "1.0.0",
+ "test-device",
+ "Test Device",
+ "127.0.0.1",
+ null);
+ session.NowPlayingItem = new BaseItemDto
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Item"
+ };
+ session.PlayState.PositionTicks = 987654321;
+
+ if (clientPositionTicks.HasValue)
+ {
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = clientPositionTicks
+ });
+ session.StopAutomaticProgress();
+ }
+
+ var idlePlaybackCallback = typeof(Emby.Server.Implementations.Session.SessionManager)
+ .GetMethod("CheckForIdlePlayback", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ idlePlaybackCallback.Invoke(sessionManager, new object?[] { null });
+
+ var stoppedPositionTicks = await playbackStopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+
+ Assert.Equal(expectedPositionTicks, stoppedPositionTicks);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs
new file mode 100644
index 0000000000..c5b8f661b5
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SessionManager;
+
+public class SessionInfoTests
+{
+ [Fact]
+ public async Task StartAutomaticProgress_SnapshotsClientReportedPosition()
+ {
+ await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
+ var progressInfo = new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 123456789
+ };
+
+ session.StartAutomaticProgress(progressInfo);
+
+ Assert.Equal(progressInfo.PositionTicks, session.LastPlaybackCheckInPositionTicks);
+ }
+
+ [Fact]
+ public async Task AutomaticProgress_AdvancesEstimatedPositionWithoutAdvancingSnapshot()
+ {
+ var sessionManager = new Mock<ISessionManager>();
+ await using var session = new SessionInfo(sessionManager.Object, NullLogger.Instance);
+ var automaticProgress = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously);
+ const long reportedPositionTicks = 123456789;
+
+ sessionManager
+ .Setup(manager => manager.OnPlaybackProgress(It.IsAny<PlaybackProgressInfo>(), true))
+ .Callback<PlaybackProgressInfo, bool>((info, _) =>
+ {
+ session.PlayState.PositionTicks = info.PositionTicks;
+ automaticProgress.TrySetResult(info.PositionTicks);
+ })
+ .Returns(Task.CompletedTask);
+ session.PlayState.PositionTicks = reportedPositionTicks;
+
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ PositionTicks = reportedPositionTicks
+ });
+
+ var estimatedPositionTicks = await automaticProgress.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ session.StopAutomaticProgress();
+
+ Assert.Equal(reportedPositionTicks + TimeSpan.TicksPerSecond, estimatedPositionTicks);
+ Assert.Equal(estimatedPositionTicks, session.PlayState.PositionTicks);
+ Assert.Equal(reportedPositionTicks, session.LastPlaybackCheckInPositionTicks);
+ }
+
+ [Fact]
+ public async Task StartAutomaticProgress_ReplacesSnapshotOnLaterClientReport()
+ {
+ await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 123456789
+ });
+
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 987654321
+ });
+
+ Assert.Equal(987654321, session.LastPlaybackCheckInPositionTicks);
+ }
+
+ [Fact]
+ public async Task StartAutomaticProgress_PreservesExactPausedPosition()
+ {
+ await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
+ var pausedProgress = new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 314159265
+ };
+
+ session.StartAutomaticProgress(pausedProgress);
+
+ Assert.Equal(pausedProgress.PositionTicks, session.LastPlaybackCheckInPositionTicks);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs
new file mode 100644
index 0000000000..bd9e680cd9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.SyncPlay;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class GroupTests
+{
+ public GroupTests()
+ {
+ var mockLogger = new Mock<ILogger<Emby.Server.Implementations.SyncPlay.Group>>();
+ MockLoggerFactory = new Mock<ILoggerFactory>();
+ MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(mockLogger.Object);
+
+ MockUserManager = new Mock<IUserManager>();
+ MockSessionManager = new Mock<ISessionManager>();
+ MockLibraryManager = new Mock<ILibraryManager>();
+ MockItem = new Mock<BaseItem>();
+ MockItem.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ }
+
+ private Mock<ILoggerFactory> MockLoggerFactory { get; }
+
+ private Mock<IUserManager> MockUserManager { get; }
+
+ private Mock<ISessionManager> MockSessionManager { get; }
+
+ private Mock<ILibraryManager> MockLibraryManager { get; }
+
+ private Mock<BaseItem> MockItem { get; }
+
+ [Fact]
+ public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible()
+ {
+ MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(MockItem.Object);
+
+ var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object);
+ var itemId = Guid.NewGuid();
+ var playlist = new List<Guid> { itemId };
+ group.PlayQueue.Reset();
+ group.PlayQueue.SetPlaylist(playlist);
+
+ Assert.Single(group.PlayQueue.GetPlaylist());
+ Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId);
+
+ var user = new User("test-user", "auth-provider", "pwdreset-provider");
+ var result = group.HasAccessToPlayQueue(user);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem()
+ {
+ MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null);
+
+ Assert.Null(MockLibraryManager.Object.GetItemById(Guid.NewGuid()));
+
+ var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object);
+ var itemId = Guid.NewGuid();
+ var playlist = new List<Guid> { itemId };
+ group.PlayQueue.Reset();
+ group.PlayQueue.SetPlaylist(playlist);
+
+ Assert.Single(group.PlayQueue.GetPlaylist());
+ Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId);
+
+ var user = new User("test-user", "auth-provider", "pwdreset-provider");
+ var result = group.HasAccessToPlayQueue(user);
+
+ Assert.False(result);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
new file mode 100644
index 0000000000..32685556b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class PlayQueueManagerTests
+{
+ private static PlayQueueManager CreateQueue(int itemCount)
+ {
+ var items = Enumerable.Range(0, itemCount).Select(_ => Guid.NewGuid()).ToList();
+ var queue = new PlayQueueManager();
+ queue.SetPlaylist(items);
+ return queue;
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndPrecedingItemRemoved_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndAllPrecedingItemsRemoved_PicksFirstRemainingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[1].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[2].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Single(queue.GetPlaylist());
+ Assert.Equal(0, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_AllItemsRemoved_ResetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ queue.SetPlayingItemByIndex(1);
+
+ var toRemove = queue.GetPlaylist().Select(item => item.PlaylistItemId).ToList();
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Empty(queue.GetPlaylist());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_ShuffleMode_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemNotRemoved_RestoresPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.False(playingItemRemoved);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Next_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Next());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Previous_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Previous());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ public void SetPlayingItemByIndex_OutOfBounds_ResetsPlayingItem(int playlistIndex)
+ {
+ var queue = CreateQueue(2);
+
+ queue.SetPlayingItemByIndex(playlistIndex);
+
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ var expectedItemId = queue.GetPlaylist()[1].ItemId;
+
+ queue.SetPlayingItemByIndex(1);
+
+ Assert.True(queue.IsItemPlaying());
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
index 92e10c9f92..4a10b2f607 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
@@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
Assert.Null(ex);
}
+
+ [Theory]
+ [InlineData("../evil")]
+ [InlineData("..\\evil")]
+ [InlineData("../../escape_attempt")]
+ [InlineData("..")]
+ [InlineData(".")]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("foo/bar")]
+ [InlineData("foo\\bar")]
+ [InlineData("/absolute")]
+ [InlineData("foo\0bar")]
+ public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name)
+ {
+ var packageInfo = new InstallationInfo()
+ {
+ Name = name,
+ SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip",
+ Checksum = "11b5b2f1a9ebc4f66d6ef19018543361"
+ };
+
+ await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
new file mode 100644
index 0000000000..8149938b4d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Threading.Tasks;
+using Jellyfin.Server.Implementations.Users;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public class UserManagerLockHelperTests
+ {
+ [Fact]
+ public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ Assert.True(helper.ShouldLock());
+
+ var outerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ var innerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ innerHandle.Dispose();
+ Assert.False(helper.ShouldLock());
+
+ outerHandle.Dispose();
+ Assert.True(helper.ShouldLock());
+ }
+
+ [Fact]
+ public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondEntered = false;
+
+ var firstTask = Task.Run(
+ async () =>
+ {
+ using var firstHandle = await helper.LockAsync(key);
+ firstAcquired.SetResult(true);
+ await releaseFirst.Task;
+ },
+ TestContext.Current.CancellationToken);
+
+ await firstAcquired.Task;
+
+ var secondTask = Task.Run(
+ async () =>
+ {
+ using var secondHandle = await helper.LockAsync(key);
+ secondEntered = true;
+ },
+ TestContext.Current.CancellationToken);
+
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+ Assert.False(secondEntered);
+
+ releaseFirst.SetResult(true);
+
+ await Task.WhenAll(firstTask, secondTask);
+ Assert.True(secondEntered);
+ }
+
+ [Fact]
+ public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ helper.Dispose();
+
+ await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+
+ helper.Dispose();
+ var ex = Record.Exception(() => helper.Dispose());
+
+ Assert.Null(ex);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs
new file mode 100644
index 0000000000..596bf58fb1
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs
@@ -0,0 +1,240 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public sealed class UserManagerNormalizedUsernameTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerNormalizedUsernameTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ new IPasswordResetProvider[] { defaultPasswordResetProvider },
+ new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ // ----- GetUserByName tests -----
+
+ [Theory]
+ // German umlauts
+ [InlineData("münchen", "MÜNCHEN")]
+ // Spanish tilde-n
+ [InlineData("Ñoño", "ÑOÑO")]
+ // ASCII, invariant uppercase lookup
+ [InlineData("jellyfin", "JELLYFIN")]
+ // Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
+ [InlineData("Çelebi", "ÇELEBI")]
+ public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
+ string username, string normalizedLookup)
+ {
+ await _userManager.CreateUserAsync(username);
+
+ var found = _userManager.GetUserByName(normalizedLookup);
+
+ Assert.NotNull(found);
+ Assert.Equal(username, found.Username);
+ }
+
+ [Theory]
+ // German umlaut, look up by both upper and lower case
+ [InlineData("münchen")]
+ // Spanish tilde-n
+ [InlineData("Ñoño")]
+ // lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
+ [InlineData("ali")]
+ // mixed ASCII + umlaut
+ [InlineData("testüser")]
+ public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
+ {
+ await _userManager.CreateUserAsync(username);
+
+ var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
+ var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
+ var exactFound = _userManager.GetUserByName(username);
+
+ Assert.NotNull(upperFound);
+ Assert.NotNull(lowerFound);
+ Assert.NotNull(exactFound);
+ }
+
+ [Theory]
+ [InlineData("nonexistent")]
+ // No user with NormalizedUsername = "MÜNCHEN" has been created
+ [InlineData("MÜNCHEN")]
+ public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
+ {
+ var result = _userManager.GetUserByName(lookupName);
+
+ Assert.Null(result);
+ }
+
+ // ----- CreateUserAsync duplicate detection tests -----
+
+ [Theory]
+ // German umlaut, case-swapped duplicate
+ [InlineData("münchen", "MÜNCHEN")]
+ // Spanish tilde-n, lowercase duplicate
+ [InlineData("Ñoño", "ñoño")]
+ // ASCII, uppercase duplicate
+ [InlineData("alice", "ALICE")]
+ // Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
+ [InlineData("çelebi", "ÇELEBI")]
+ public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
+ string existingUsername, string duplicateUsername)
+ {
+ await _userManager.CreateUserAsync(existingUsername);
+
+ await Assert.ThrowsAsync<ArgumentException>(
+ () => _userManager.CreateUserAsync(duplicateUsername));
+ }
+
+ [Theory]
+ // Different non-ASCII names that do not collide after normalization
+ [InlineData("münchen", "münchen2")]
+ [InlineData("ali", "ali2")]
+ // Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
+ [InlineData("noño", "nono")]
+ public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
+ string firstUsername, string secondUsername)
+ {
+ var first = await _userManager.CreateUserAsync(firstUsername);
+ var second = await _userManager.CreateUserAsync(secondUsername);
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.NotEqual(first.Id, second.Id);
+ }
+
+ // ----- RenameUser tests -----
+
+ [Theory]
+ // Rename to non-ASCII name
+ [InlineData("alice", "münchen")]
+ // Rename between similar non-ASCII and ASCII
+ [InlineData("müller", "mueller")]
+ // Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
+ [InlineData("ali", "ALI2")]
+ // Rename to Spanish tilde-n name
+ [InlineData("testuser", "Ñoño")]
+ public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
+ string originalName, string newName)
+ {
+ var user = await _userManager.CreateUserAsync(originalName);
+
+ await _userManager.RenameUser(user.Id, originalName, newName);
+
+ var renamed = _userManager.GetUserById(user.Id);
+ Assert.NotNull(renamed);
+ Assert.Equal(newName, renamed.Username);
+ Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
+ }
+
+ [Theory]
+ // Same name different case: NormalizedUsername already taken
+ [InlineData("münchen", "MÜNCHEN")]
+ // Spanish, lowercase conflicts with existing uppercase-normalised entry
+ [InlineData("Ñoño", "ñoño")]
+ // ASCII, capitalised conflict
+ [InlineData("alice", "Alice")]
+ // Mixed ASCII + umlaut
+ [InlineData("testüser", "TESTÜSER")]
+ public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
+ string existingUsername, string conflictingNewName)
+ {
+ var targetUser = await _userManager.CreateUserAsync("renametarget");
+ await _userManager.CreateUserAsync(existingUsername);
+
+ await Assert.ThrowsAsync<ArgumentException>(
+ () => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
new file mode 100644
index 0000000000..cb714a4014
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.IO;
+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.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public sealed class UserManagerProfileImageTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerProfileImageTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ new IPasswordResetProvider[] { defaultPasswordResetProvider },
+ new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ [Fact]
+ public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage()
+ {
+ var user = await _userManager.CreateUserAsync("profileimageuser");
+
+ // Assign a profile image the same way the image endpoint does and persist it.
+ // UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity,
+ // so the in-memory instance below is never assigned the database generated key.
+ user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png"));
+ await _userManager.UpdateUserAsync(user);
+
+ // Precondition reproducing the bug: the in-memory image still carries the default,
+ // never-persisted (temporary) key, while a real image row exists in the database.
+ Assert.Equal(0, user.ProfileImage.Id);
+ Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage);
+
+ // This used to throw InvalidOperationException:
+ // "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'."
+ var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
+
+ Assert.Null(exception);
+ Assert.Null(user.ProfileImage);
+ Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage);
+ }
+
+ [Fact]
+ public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing()
+ {
+ var user = await _userManager.CreateUserAsync("noprofileimageuser");
+
+ var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
+
+ Assert.Null(exception);
+ Assert.Null(user.ProfileImage);
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
index 4cea53bd3d..2bf1d1d05b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
@@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users
[InlineData(" thishasaspaceatthestart")]
[InlineData(" thishasaspaceatbothends ")]
[InlineData(" this has a space at both ends and inbetween ")]
+ [InlineData(".")]
+ [InlineData("..")]
public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username)
{
Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username));
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
new file mode 100644
index 0000000000..c940f92109
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+public sealed class UserManagerUpdateUserTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerUpdateUserTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ [defaultPasswordResetProvider],
+ [defaultAuthProvider, invalidAuthProvider]);
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences()
+ {
+ var user = await _userManager.CreateUserAsync("orphanuser");
+ var permissionCount = user.Permissions.Count;
+ var preferenceCount = user.Preferences.Count;
+
+ user.LastActivityDate = DateTime.UtcNow;
+ await _userManager.UpdateUserAsync(user);
+ await _userManager.UpdateUserAsync(user);
+
+ await using var context = CreateDbContext();
+ Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
+ Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken));
+ Assert.All(
+ await context.Permissions.ToListAsync(TestContext.Current.CancellationToken),
+ permission => Assert.Equal(user.Id, permission.UserId));
+ Assert.All(
+ await context.Preferences.ToListAsync(TestContext.Current.CancellationToken),
+ preference => Assert.Equal(user.Id, preference.UserId));
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched()
+ {
+ var user = await _userManager.CreateUserAsync("churnuser");
+ var before = await ReadChildRowsAsync();
+
+ // A session activity stamp goes through the same path. It must not rewrite all 37 child
+ // rows, which is what tearing the collections down and rebuilding them used to do.
+ user.LastActivityDate = DateTime.UtcNow;
+ await _userManager.UpdateUserAsync(user);
+
+ Assert.Equal(before, await ReadChildRowsAsync());
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges()
+ {
+ var user = await _userManager.CreateUserAsync("policyuser");
+ Assert.False(user.HasPermission(PermissionKind.IsAdministrator));
+
+ user.SetPermission(PermissionKind.IsAdministrator, true);
+ user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]);
+ user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels));
+
+ await _userManager.UpdateUserAsync(user);
+
+ var reloaded = _userManager.GetUserById(user.Id)!;
+ Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator));
+ Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags));
+ Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels);
+
+ await using var context = CreateDbContext();
+ Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ /// <summary>
+ /// Reads the identity and concurrency token of every permission and preference row.
+ /// </summary>
+ private async Task<List<(string Table, int Id, int Kind, uint RowVersion)>> ReadChildRowsAsync()
+ {
+ await using var context = CreateDbContext();
+ var permissions = await context.Permissions
+ .OrderBy(permission => permission.Id)
+ .Select(permission => new ValueTuple<string, int, int, uint>("Permission", permission.Id, (int)permission.Kind, permission.RowVersion))
+ .ToListAsync(TestContext.Current.CancellationToken);
+ var preferences = await context.Preferences
+ .OrderBy(preference => preference.Id)
+ .Select(preference => new ValueTuple<string, int, int, uint>("Preference", preference.Id, (int)preference.Kind, preference.RowVersion))
+ .ToListAsync(TestContext.Current.CancellationToken);
+
+ return permissions.Concat(preferences).ToList();
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+}