aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests/Item
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Item')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs173
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs79
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs158
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs157
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs103
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs121
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs175
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs44
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs64
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs195
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs191
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs103
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs159
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs85
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs194
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs114
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs85
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryNameRangeTests.cs53
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs138
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs4
20 files changed, 2384 insertions, 11 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
new file mode 100644
index 0000000000..0c1d453293
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Moq;
+using Xunit;
+using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ArtistNameLookupTests : SqliteDbTestFixture
+{
+ private readonly CommandRecorder _recorder;
+
+ public ArtistNameLookupTests()
+ : this(new CommandRecorder())
+ {
+ }
+
+ private ArtistNameLookupTests(CommandRecorder recorder)
+ : base(recorder)
+ {
+ _recorder = recorder;
+ }
+
+ [Theory]
+ [InlineData("Björk", "bjork")]
+ [InlineData("AC/DC", "ac dc")]
+ [InlineData("An Artist", " AN ARTIST ")]
+ [InlineData("Artist", "ARTIST")]
+ public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName)
+ {
+ var lookup = new ItemTypeLookup();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = artistId,
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.Person]
+ });
+ context.SaveChanges();
+ }
+
+ var manager = CreateLibraryManager(lookup);
+ _recorder.Commands.Clear();
+
+ Assert.Equal(artistId, manager.GetArtist(requestedName).Id);
+ var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal));
+ Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal));
+ Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id);
+ }
+
+ [Fact]
+ public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch()
+ {
+ var lookup = new ItemTypeLookup();
+ var parentId = Guid.NewGuid();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = parentId,
+ Name = "Music",
+ Type = lookup.BaseItemKindNames[BaseItemKind.Folder]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = "Bjork",
+ CleanName = "Bjork".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = artistId,
+ ParentId = parentId,
+ Name = "Björk",
+ CleanName = "Björk".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ });
+ context.SaveChanges();
+ }
+
+ Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id);
+ }
+
+ private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup)
+ {
+ var repository = CreateBaseItemRepository(lookup);
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ fixture.Inject<IItemRepository>(repository);
+ fixture.Inject<ILinkedChildrenService>(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository));
+ return fixture.Create<ServerLibraryManager>();
+ }
+
+ private string[] Explain(RecordedCommand query)
+ {
+ using var context = CreateDbContext();
+ using var command = context.Database.GetDbConnection().CreateCommand();
+#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters.
+ command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql;
+#pragma warning restore CA2100
+ foreach (var value in query.Parameters)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = value.Name;
+ parameter.Value = value.Value;
+ command.Parameters.Add(parameter);
+ }
+
+ using var reader = command.ExecuteReader();
+ var plan = new List<string>();
+ while (reader.Read())
+ {
+ plan.Add(reader.GetString(3));
+ }
+
+ return plan.ToArray();
+ }
+
+ private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters);
+
+ private sealed class CommandRecorder : DbCommandInterceptor
+ {
+ public List<RecordedCommand> Commands { get; } = [];
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ private void Record(DbCommand command) => Commands.Add(new RecordedCommand(
+ command.CommandText,
+ command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs
new file mode 100644
index 0000000000..abe1e59496
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the user data rows <see cref="BaseItemMapper"/> hands the domain item. A domain item is
+/// held for as long as its folder holds it, so a row that still points back at the entity it was
+/// read with would keep that entity - and everything loaded alongside it - alive with it.
+/// </summary>
+public class BaseItemMapperUserDataTests
+{
+ [Fact]
+ public void Map_CopiesUserDataWithoutTheEntityGraphBehindIt()
+ {
+ var itemId = Guid.NewGuid();
+ var userId = Guid.NewGuid();
+ var user = new User("someone", "Default", "Default");
+ var entity = new BaseItemEntity { Id = itemId, Type = "MediaBrowser.Controller.Entities.TV.Episode" };
+
+ var row = new UserData
+ {
+ ItemId = itemId,
+ Item = entity,
+ UserId = userId,
+ User = user,
+ CustomDataKey = "key",
+ PlayCount = 3,
+ PlaybackPositionTicks = 1234,
+ IsFavorite = true,
+ Played = true,
+ Rating = 7.5,
+ LastPlayedDate = new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc),
+ AudioStreamIndex = 1,
+ SubtitleStreamIndex = 2,
+ Likes = true
+ };
+
+ entity.UserData = [row];
+
+ var dto = BaseItemMapper.Map(entity, new Folder(), null);
+
+ var mapped = Assert.Single(dto.UserData);
+ Assert.Null(mapped.Item);
+ Assert.Null(mapped.User);
+
+ // The values callers actually read still come through.
+ Assert.Equal(itemId, mapped.ItemId);
+ Assert.Equal(userId, mapped.UserId);
+ Assert.Equal("key", mapped.CustomDataKey);
+ Assert.Equal(3, mapped.PlayCount);
+ Assert.Equal(1234, mapped.PlaybackPositionTicks);
+ Assert.True(mapped.IsFavorite);
+ Assert.True(mapped.Played);
+ Assert.Equal(7.5, mapped.Rating);
+ Assert.Equal(row.LastPlayedDate, mapped.LastPlayedDate);
+ Assert.Equal(1, mapped.AudioStreamIndex);
+ Assert.Equal(2, mapped.SubtitleStreamIndex);
+ Assert.True(mapped.Likes);
+ }
+
+ [Fact]
+ public void Map_WithoutUserData_YieldsAnEmptyCollection()
+ {
+ var entity = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.Folder"
+ };
+
+ var dto = BaseItemMapper.Map(entity, new Folder(), null);
+
+ Assert.Empty(dto.UserData);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs
new file mode 100644
index 0000000000..3c8bd9fd50
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryAllowedTagsByNameTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// A user restricted to a set of allowed tags still has to see the by-name items - genres, studios,
+/// artists - of the media those tags let through. The by-name item carries none of the media's tags,
+/// so the allowed-tags filter would otherwise hide every one of them.
+/// </summary>
+public sealed class BaseItemRepositoryAllowedTagsByNameTests : SqliteDbTestFixture
+{
+ private const string AllowedTag = "kids";
+
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryAllowedTagsByNameTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+ Seed();
+ }
+
+ [Fact]
+ public void GetGenres_WithAllowedTags_ReturnsGenresOfAllowedItems()
+ {
+ var result = _repository.GetGenres(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Comedy"], Names(result));
+ }
+
+ [Fact]
+ public void GetStudios_WithAllowedTags_ReturnsStudiosOfAllowedItems()
+ {
+ var result = _repository.GetStudios(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Pixar"], Names(result));
+ }
+
+ [Fact]
+ public void GetAllArtists_WithAllowedTags_ReturnsArtistsOfAllowedItems()
+ {
+ var result = _repository.GetAllArtists(CreateQuery(AllowedTag));
+
+ Assert.Equal(["Raffi"], Names(result));
+ }
+
+ [Fact]
+ public void GetGenres_WithoutAllowedTags_ReturnsEveryGenre()
+ {
+ var result = _repository.GetGenres(CreateQuery());
+
+ Assert.Equal(["Comedy", "Horror", "Jazz"], Names(result));
+ }
+
+ private static string[] Names(MediaBrowser.Model.Querying.QueryResult<(BaseItem Item, MediaBrowser.Model.Dto.ItemCounts? ItemCounts)> result)
+ => result.Items.Select(i => i.Item.Name!).Order(StringComparer.Ordinal).ToArray();
+
+ private static InternalItemsQuery CreateQuery(params string[] allowedTags)
+ {
+ var user = new User("restricted", "auth", "reset");
+ if (allowedTags.Length > 0)
+ {
+ user.SetPreference(PreferenceKind.AllowedTags, allowedTags);
+ }
+
+ return new InternalItemsQuery(user);
+ }
+
+ private void Seed()
+ {
+ using var context = CreateDbContext();
+
+ // Tagged media, plus the by-name items describing it.
+ var movie = CreateItem(BaseItemKind.Movie, "Allowed Movie", mediaType: "Video");
+ var song = CreateItem(BaseItemKind.Audio, "Allowed Song", mediaType: "Audio");
+ var genre = CreateItem(BaseItemKind.Genre, "Comedy");
+ var studio = CreateItem(BaseItemKind.Studio, "Pixar");
+ var artist = CreateItem(BaseItemKind.MusicArtist, "Raffi");
+
+ // Media the allow list keeps out, plus the by-name items only it is described by.
+ var blockedMovie = CreateItem(BaseItemKind.Movie, "Untagged Movie", mediaType: "Video");
+ var blockedGenre = CreateItem(BaseItemKind.Genre, "Horror");
+
+ // A genre written on nothing but a by-name item: that item is no more visible than the
+ // media behind it, so the genre must not leak into a restricted user's list.
+ var artistOnlyGenre = CreateItem(BaseItemKind.Genre, "Jazz");
+ var blockedArtist = CreateItem(BaseItemKind.MusicArtist, "Mingus");
+
+ var tag = CreateItemValue(ItemValueType.Tags, AllowedTag);
+ var comedy = CreateItemValue(ItemValueType.Genre, "Comedy");
+ var horror = CreateItemValue(ItemValueType.Genre, "Horror");
+ var jazz = CreateItemValue(ItemValueType.Genre, "Jazz");
+ var pixar = CreateItemValue(ItemValueType.Studios, "Pixar");
+ var raffi = CreateItemValue(ItemValueType.Artist, "Raffi");
+
+ context.BaseItems.AddRange(movie, song, genre, studio, artist, blockedMovie, blockedGenre, artistOnlyGenre, blockedArtist);
+ context.ItemValues.AddRange(tag, comedy, horror, jazz, pixar, raffi);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, tag),
+ CreateMap(movie, comedy),
+ CreateMap(movie, pixar),
+ CreateMap(song, tag),
+ CreateMap(song, raffi),
+ CreateMap(blockedMovie, horror),
+ CreateMap(blockedArtist, jazz));
+ context.SaveChanges();
+ }
+
+ private BaseItemEntity CreateItem(BaseItemKind kind, string name, string? mediaType = null)
+ {
+ var id = Guid.NewGuid();
+
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _itemTypeLookup.BaseItemKindNames[kind],
+ Name = name,
+ CleanName = name.ToLowerInvariant(),
+ PresentationUniqueKey = id.ToString("N"),
+ MediaType = mediaType,
+ IsMovie = kind == BaseItemKind.Movie,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private static ItemValue CreateItemValue(ItemValueType type, string value)
+ {
+ return new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = value,
+ CleanValue = value.ToLowerInvariant()
+ };
+ }
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ {
+ return new ItemValueMap
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs
new file mode 100644
index 0000000000..1f8d9820d6
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryArtistLibraryScopeTests.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Artists are by-name items: they live outside any library and carry no TopParentId, so a plain
+/// item query for them is exempt from the library filter and spans every music library. Only the
+/// by-name listings, which reach the artist through the tracks that credit it, can be scoped to
+/// one library.
+/// </summary>
+public sealed class BaseItemRepositoryArtistLibraryScopeTests : SqliteDbTestFixture
+{
+ private static readonly Guid _firstLibrary = Guid.Parse("11111111-0000-0000-0000-000000000001");
+ private static readonly Guid _secondLibrary = Guid.Parse("22222222-0000-0000-0000-000000000001");
+
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryArtistLibraryScopeTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+
+ Seed("First Artist", "first artist", _firstLibrary, hasImage: true);
+ Seed("Second Artist", "second artist", _secondLibrary, hasImage: true);
+ }
+
+ [Fact]
+ public void GetItemList_MusicArtistsScopedToOneLibrary_ReturnsEveryLibrarysArtists()
+ {
+ // The shape the library cover image used to be built from. By-name types are exempt from
+ // the TopParentId filter, so the scope is silently dropped.
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ IncludeItemTypes = [BaseItemKind.MusicArtist],
+ TopParentIds = [_firstLibrary]
+ });
+
+ Assert.Equal(["First Artist", "Second Artist"], result.Select(i => i.Name).OrderBy(n => n));
+ }
+
+ [Fact]
+ public void GetAllArtists_ScopedToOneLibrary_ReturnsOnlyThatLibrarysArtists()
+ {
+ var result = _repository.GetAllArtists(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ TopParentIds = [_firstLibrary]
+ });
+
+ var (artist, _) = Assert.Single(result.Items);
+ Assert.Equal("First Artist", artist.Name);
+ }
+
+ [Fact]
+ public void GetAllArtists_ImageTypes_DropsArtistsWithoutThatImage()
+ {
+ // The collage has nothing to draw with an artist that has no image, so the listing has to
+ // honour the image filter the caller asked for.
+ Seed("Third Artist", "third artist", _firstLibrary, hasImage: false);
+
+ var result = _repository.GetAllArtists(new InternalItemsQuery
+ {
+ DtoOptions = new DtoOptions(false),
+ ImageTypes = [ImageType.Primary],
+ TopParentIds = [_firstLibrary]
+ });
+
+ var (artist, _) = Assert.Single(result.Items);
+ Assert.Equal("First Artist", artist.Name);
+ }
+
+ /// <summary>
+ /// Seeds one by-name artist row and a track in the given library crediting it.
+ /// </summary>
+ /// <param name="name">The artist name.</param>
+ /// <param name="cleanName">The cleaned artist name, which is what links the two rows.</param>
+ /// <param name="topParentId">The library the track belongs to.</param>
+ /// <param name="hasImage">Whether the artist row carries a primary image.</param>
+ private void Seed(string name, string cleanName, Guid topParentId, bool hasImage)
+ {
+ using var ctx = CreateDbContext();
+
+ var artistId = Guid.NewGuid();
+ var artist = new BaseItemEntity
+ {
+ Id = artistId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist],
+ Name = name,
+ CleanName = cleanName,
+ PresentationUniqueKey = artistId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ };
+
+ if (hasImage)
+ {
+ artist.Images =
+ [
+ new BaseItemImageInfo
+ {
+ Id = Guid.NewGuid(),
+ ItemId = artistId,
+ Item = artist,
+ ImageType = ImageInfoImageType.Primary,
+ Path = $"/metadata/artists/{cleanName}/folder.jpg"
+ }
+ ];
+ }
+
+ ctx.BaseItems.Add(artist);
+
+ var trackId = Guid.NewGuid();
+ var track = new BaseItemEntity
+ {
+ Id = trackId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio],
+ Name = $"{name} - Track",
+ CleanName = $"{cleanName} - track",
+ PresentationUniqueKey = trackId.ToString("N"),
+ MediaType = "Audio",
+ TopParentId = topParentId,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ ctx.BaseItems.Add(track);
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.AlbumArtist,
+ Value = name,
+ CleanValue = cleanName
+ };
+
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = trackId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = track,
+ ItemValue = itemValue
+ });
+
+ ctx.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs
new file mode 100644
index 0000000000..5e045e9f83
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the children query the library scan runs against a folder: a version merged by hand is
+/// hidden from ordinary queries, but the scan has to see it or it takes the row for a new item and
+/// recreates it, splitting the version group apart again.
+/// </summary>
+public sealed class BaseItemRepositoryChildrenTests : SqliteDbTestFixture
+{
+ private static readonly Guid _folderId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _primaryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ private static readonly Guid _mergedVersionId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ private static readonly Guid _ownedVersionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ private readonly BaseItemRepository _repository;
+
+ public BaseItemRepositoryChildrenTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+
+ var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ using var ctx = CreateDbContext();
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _folderId,
+ Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!,
+ Name = "Movies",
+ Path = "/movies",
+ IsFolder = true
+ });
+ ctx.BaseItems.Add(CreateMovie(_primaryId, movieTypeName!, "Big Buck Bunny", "/media1/Big Buck Bunny/bbb-1080p.mp4", null, null));
+ ctx.BaseItems.Add(CreateMovie(_mergedVersionId, movieTypeName!, "Big Buck Bunny", "/media2/Big Buck Bunny/bbb-2160p.mp4", _primaryId, null));
+ ctx.BaseItems.Add(CreateMovie(_ownedVersionId, movieTypeName!, "Big Buck Bunny - 720p", "/media1/Big Buck Bunny/bbb-720p.mp4", _primaryId, _primaryId));
+ ctx.SaveChanges();
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolder_ExcludesAlternateVersionsByDefault()
+ {
+ var result = _repository.GetItemList(new InternalItemsQuery { ParentId = _folderId });
+
+ var item = Assert.Single(result);
+ Assert.Equal(_primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_KeepsMergedVersion()
+ {
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ ParentId = _folderId,
+ IncludeAlternateVersions = true
+ });
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, i => i.Id.Equals(_primaryId));
+ Assert.Contains(result, i => i.Id.Equals(_mergedVersionId));
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_StillExcludesOwnedVersion()
+ {
+ // A version stored next to the file it belongs to is owned by its primary and is never
+ // resolved on its own, so the scan must not see it as a child of the folder either.
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ ParentId = _folderId,
+ IncludeAlternateVersions = true
+ });
+
+ Assert.DoesNotContain(result, i => i.Id.Equals(_ownedVersionId));
+ }
+
+ private static BaseItemEntity CreateMovie(Guid id, string typeName, string name, string path, Guid? primaryVersionId, Guid? ownerId)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = typeName,
+ Name = name,
+ Path = path,
+ ParentId = _folderId,
+ TopParentId = _folderId,
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"),
+ PrimaryVersionId = primaryVersionId,
+ OwnerId = ownerId,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
index 535961a66c..9238ec9fd1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -13,13 +13,18 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
{
+ private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
private readonly BaseItemRepository _repository;
private readonly string _movieTypeName;
+ private readonly string _folderTypeName;
public BaseItemRepositoryGroupingTests()
{
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder];
_repository = CreateBaseItemRepository(itemTypeLookup);
}
@@ -67,6 +72,118 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
Assert.Equal(firstId, item.Id);
}
+ [Fact]
+ public void GetItemList_LibraryWithoutThePrimaryOfTheGroup_KeepsTheVersionVisible()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+ var sameLibraryPrimaryId = Guid.Parse("55555555-5555-5555-5555-555555555555");
+ var sameLibraryVersionId = Guid.Parse("66666666-6666-6666-6666-666666666666");
+
+ SeedCrossLibraryGroup(primaryId, versionId, sameLibraryPrimaryId, sameLibraryVersionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId));
+
+ // The version stands in for the group in the library it lives in, because its primary is in
+ // a library of its own; a group merged inside this library still collapses onto its primary.
+ Assert.Contains(result, i => i.Id.Equals(versionId));
+ Assert.Contains(result, i => i.Id.Equals(sameLibraryPrimaryId));
+ Assert.DoesNotContain(result, i => i.Id.Equals(sameLibraryVersionId));
+ Assert.DoesNotContain(result, i => i.Id.Equals(primaryId));
+ }
+
+ [Fact]
+ public void GetItemList_LibraryHoldingThePrimary_ReturnsThePrimary()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movie4KLibraryId));
+
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_BothLibrariesOfACrossLibraryGroup_ReturnsItOnce()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId, _movie4KLibraryId));
+
+ // With both libraries in scope the presentation key grouping collapses the version.
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItems_LibraryWithoutThePrimaryOfTheGroup_CountsWhatItLists()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var listed = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId)).Count;
+
+ var query = CreateLibraryQuery(_movieLibraryId);
+ query.EnableTotalRecordCount = true;
+ query.Limit = 1;
+
+ // The total the client pages against has to agree with the listing.
+ Assert.Equal(1, listed);
+ Assert.Equal(listed, _repository.GetItems(query).TotalRecordCount);
+ }
+
+ private static InternalItemsQuery CreateLibraryQuery(params Guid[] topParentIds)
+ {
+ return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie],
+ TopParentIds = topParentIds
+ };
+ }
+
+ private void SeedCrossLibraryGroup(
+ Guid primaryId,
+ Guid versionId,
+ Guid? sameLibraryPrimaryId = null,
+ Guid? sameLibraryVersionId = null)
+ {
+ using var ctx = CreateDbContext();
+ ctx.BaseItems.Add(CreateFolderEntity(_movieLibraryId, "Movies"));
+ ctx.BaseItems.Add(CreateFolderEntity(_movie4KLibraryId, "Movies-4K"));
+
+ // The 4K version heads the group and lives in a library of its own.
+ ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie - 4K", primaryId.ToString("N"), null, _movie4KLibraryId));
+ ctx.BaseItems.Add(CreateMovieEntity(versionId, "Movie", primaryId.ToString("N"), primaryId, _movieLibraryId));
+
+ if (sameLibraryPrimaryId.HasValue && sameLibraryVersionId.HasValue)
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(sameLibraryPrimaryId.Value, "Other - 4K", sameLibraryPrimaryId.Value.ToString("N"), null, _movieLibraryId));
+ ctx.BaseItems.Add(CreateMovieEntity(sameLibraryVersionId.Value, "Other", sameLibraryPrimaryId.Value.ToString("N"), sameLibraryPrimaryId.Value, _movieLibraryId));
+ }
+
+ ctx.SaveChanges();
+ }
+
+ private BaseItemEntity CreateFolderEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _folderTypeName,
+ Name = name,
+ Path = "/" + name,
+ IsFolder = true
+ };
+ }
+
private static InternalItemsQuery CreateQuery()
{
// IncludeOwnedItems keeps the alternate version rows in the query so the
@@ -78,13 +195,15 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
};
}
- private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId)
+ private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId, Guid? libraryId = null)
{
return new BaseItemEntity
{
Id = id,
Type = _movieTypeName,
Name = name,
+ ParentId = libraryId,
+ TopParentId = libraryId,
PresentationUniqueKey = presentationKey,
PrimaryVersionId = primaryVersionId,
MediaType = "Video",
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs
new file mode 100644
index 0000000000..d995293923
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryInheritedTagTests.cs
@@ -0,0 +1,175 @@
+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.Extensions;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the inherited-tag filters a user's blocked and allowed tags turn into. A tag reaches an item
+/// four ways - on the item, on its series, on an ancestor and on its library - and each of them has to
+/// keep answering the same after the ancestor check moved off a per-row correlated subquery.
+/// </summary>
+public sealed class BaseItemRepositoryInheritedTagTests : SqliteDbTestFixture
+{
+ private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+
+ private const string Tag = "Adult";
+
+ private readonly BaseItemRepository _repository;
+
+ private readonly Guid _library = Guid.NewGuid();
+ private readonly Guid _otherLibrary = Guid.NewGuid();
+
+ // Tagged on the item itself.
+ private readonly Guid _taggedSeries = Guid.NewGuid();
+
+ // Inherits the tag from the series it belongs to, without an ancestor row for it.
+ private readonly Guid _episodeOfTaggedSeries = Guid.NewGuid();
+
+ // Inherits the tag from a season in the middle of its ancestor chain.
+ private readonly Guid _taggedSeason = Guid.NewGuid();
+ private readonly Guid _episodeUnderTaggedSeason = Guid.NewGuid();
+
+ // Inherits the tag from the library above it.
+ private readonly Guid _taggedLibrarySeries = Guid.NewGuid();
+
+ // Carries the tag nowhere, the control the assertions are read against.
+ private readonly Guid _untaggedSeries = Guid.NewGuid();
+ private readonly Guid _untaggedEpisode = Guid.NewGuid();
+
+ public BaseItemRepositoryInheritedTagTests()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ Seed(ctx);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void ExcludeInheritedTags_DropsEveryItemTheTagReaches()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = [Tag] }).ToHashSet();
+
+ // The blocked library carries the tag itself, so it goes with everything under it.
+ Assert.Equal(
+ new[] { _library, _untaggedSeries, _untaggedEpisode }.Order(),
+ ids.Order());
+ }
+
+ [Fact]
+ public void IncludeInheritedTags_KeepsExactlyTheItemsTheTagReaches()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { IncludeInheritedTags = [Tag] }).ToHashSet();
+
+ Assert.Equal(
+ new[] { _otherLibrary, _taggedSeries, _episodeOfTaggedSeries, _taggedSeason, _episodeUnderTaggedSeason, _taggedLibrarySeries }.Order(),
+ ids.Order());
+ }
+
+ [Fact]
+ public void ExcludeInheritedTags_DropsAnItemReachedOnlyThroughAnAncestor()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Episode],
+ ExcludeInheritedTags = [Tag]
+ });
+
+ Assert.Equal([_untaggedEpisode], ids);
+ }
+
+ [Fact]
+ public void ExcludeInheritedTags_WithAnUnusedTag_KeepsEverything()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { ExcludeInheritedTags = ["Unused"] });
+
+ Assert.Equal(9, ids.Count);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ AddItem(context, _library, FolderType, "Shows", true);
+ AddItem(context, _otherLibrary, FolderType, "Blocked library", true);
+ AddItem(context, _taggedSeries, SeriesType, "Tagged series", true);
+ AddItem(context, _episodeOfTaggedSeries, EpisodeType, "Episode of tagged series", false, _taggedSeries);
+ AddItem(context, _taggedSeason, SeasonType, "Tagged season", true);
+ AddItem(context, _episodeUnderTaggedSeason, EpisodeType, "Episode under tagged season", false);
+ AddItem(context, _taggedLibrarySeries, SeriesType, "Series in blocked library", true);
+ AddItem(context, _untaggedSeries, SeriesType, "Untagged series", true);
+ AddItem(context, _untaggedEpisode, EpisodeType, "Untagged episode", false, _untaggedSeries);
+
+ // AncestorIds is a closure: production writes one row per ancestor, not just the parent. The
+ // episode of the tagged series deliberately has none, so the series branch is what has to catch it.
+ AddAncestors(context, _taggedSeries, _library);
+ AddAncestors(context, _taggedSeason, _library);
+ AddAncestors(context, _episodeUnderTaggedSeason, _taggedSeason, _library);
+ AddAncestors(context, _taggedLibrarySeries, _otherLibrary);
+ AddAncestors(context, _untaggedSeries, _library);
+ AddAncestors(context, _untaggedEpisode, _untaggedSeries, _library);
+
+ Tagged(context, _taggedSeries, _taggedSeason, _otherLibrary);
+
+ context.SaveChanges();
+ }
+
+ private void AddItem(JellyfinDbContext context, Guid id, string type, string name, bool isFolder, Guid? seriesId = null)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = type,
+ Name = name,
+ IsFolder = isFolder,
+ SeriesId = seriesId
+ });
+ }
+
+ private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
+ {
+ foreach (var ancestorId in ancestorIds)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = ancestorId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ private void Tagged(JellyfinDbContext context, params Guid[] itemIds)
+ {
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = Tag,
+ CleanValue = Tag.GetCleanValue()
+ };
+
+ context.ItemValues.Add(itemValue);
+ foreach (var itemId in itemIds)
+ {
+ context.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = itemId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = null!,
+ ItemValue = null!
+ });
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
index 91148501ce..039693c432 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
@@ -104,6 +104,50 @@ public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture
}
[Fact]
+ public void GetTagNames_GroupsAndFiltersItemValues()
+ {
+ var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
+ var otherMovie = CreateMovieEntity(Guid.NewGuid(), "Other Movie");
+ var audio = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Excluded Audio",
+ MediaType = "Audio",
+ IsMovie = false,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var tag = CreateItemValue(ItemValueType.Tags, "Alpha", "alpha");
+ var duplicateTag = CreateItemValue(ItemValueType.Tags, "alpha", "alpha");
+ var otherTag = CreateItemValue(ItemValueType.Tags, "Beta", "beta");
+ var inheritedTag = CreateItemValue(ItemValueType.InheritedTags, "Inherited", "inherited");
+ var genre = CreateItemValue(ItemValueType.Genre, "Genre Leak", "genre leak");
+ var excludedTag = CreateItemValue(ItemValueType.Tags, "Excluded Tag", "excluded tag");
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(movie, otherMovie, audio);
+ context.ItemValues.AddRange(tag, duplicateTag, otherTag, inheritedTag, genre, excludedTag);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, tag),
+ CreateMap(movie, duplicateTag),
+ CreateMap(otherMovie, otherTag),
+ CreateMap(movie, inheritedTag),
+ CreateMap(movie, genre),
+ CreateMap(audio, excludedTag));
+ context.SaveChanges();
+ }
+
+ var result = _repository.GetTagNames(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal(["Alpha", "Beta"], result);
+ }
+
+ [Fact]
public void GetGenreNames_GroupsAndFiltersMappedItemValues()
{
var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
index 0958512b1a..a9548a6d13 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
@@ -32,6 +32,8 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid();
private readonly Guid _unplayedSeries = Guid.NewGuid();
+ private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid();
+ private readonly Guid _partiallyPlayedSeries = Guid.NewGuid();
public BaseItemRepositoryPlayedVersionTests()
{
@@ -68,8 +70,29 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
[Fact]
public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
{
- Assert.Equal(new HashSet<Guid> { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true));
- Assert.Equal(new HashSet<Guid> { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false));
+ Assert.Equal(
+ new HashSet<Guid> { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions },
+ Ids(BaseItemKind.Series, isPlayed: true));
+ Assert.Equal(
+ new HashSet<Guid> { _unplayedSeries, _partiallyPlayedSeries },
+ Ids(BaseItemKind.Series, isPlayed: false));
+ }
+
+ [Fact]
+ public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
+ {
+ Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true));
+ Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true));
+ }
+
+ [Fact]
+ public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion()
+ {
+ var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true });
+
+ // Nothing is left to watch, so the series is not half finished.
+ Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable);
+ Assert.Contains(_partiallyPlayedSeries, resumable);
}
private HashSet<Guid> Ids(BaseItemKind kind, bool isPlayed)
@@ -95,6 +118,9 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true);
AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false);
+ AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true);
+ AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false);
+
context.SaveChanges();
}
@@ -113,7 +139,33 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
{
var episodeId = Guid.NewGuid();
- context.BaseItems.Add(new BaseItemEntity
+ AddSeriesFolder(context, seriesId, name);
+
+ AddItem(context, episodeId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+
+ AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
+ }
+
+ // A watched first episode plus a second one that is either watched as its alternate version or not
+ // watched at all, which is what separates a finished series from a half watched one.
+ private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate)
+ {
+ AddSeriesFolder(context, seriesId, name);
+
+ var firstId = Guid.NewGuid();
+ AddItem(context, firstId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddPlayedUserData(context, firstId);
+
+ var secondId = Guid.NewGuid();
+ AddItem(context, secondId, EpisodeType, $"{name} 2");
+ context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate);
+ }
+
+ private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name)
+ => context.BaseItems.Add(new BaseItemEntity
{
Id = seriesId,
Type = SeriesType,
@@ -123,12 +175,6 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
IsFolder = true
});
- AddItem(context, episodeId, EpisodeType, $"{name} 1");
- context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
-
- AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
- }
-
private void AddItem(JellyfinDbContext context, Guid id, string type, string name)
=> context.BaseItems.Add(new BaseItemEntity
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
index ff683dc57a..787bb24150 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -201,6 +201,201 @@ public sealed class ItemCountServiceTests : IDisposable
}
[Fact]
+ public void GetCounts_PlayedAlternateVersion_CountThePrimaryAsPlayed()
+ {
+ var user = new User("alt-version-test", "provider", "reset");
+ var seriesId = Guid.NewGuid();
+ var primaryId = Guid.NewGuid();
+ var alternateId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ var series = CreateItem(seriesId);
+ series.PresentationUniqueKey = "alt-version-series";
+ context.BaseItems.Add(series);
+
+ context.BaseItems.Add(CreateLeaf(primaryId));
+ var alternate = CreateLeaf(alternateId);
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+ context.SaveChanges();
+
+ // Only the primary is counted as a leaf, as ApplyAccessFiltering leaves it in production.
+ AddAncestor(context, primaryId, seriesId);
+
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = primaryId,
+ ChildId = alternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ // The file that was watched is the alternate, so the primary carries no played row.
+ context.UserData.Add(new UserData
+ {
+ ItemId = alternateId,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ // The per-item paths have to agree with the batch one, which the DTO uses interchangeably.
+ Assert.Equal(1, _service.GetPlayedCount(filter, seriesId));
+ Assert.Equal((1, 1), _service.GetPlayedAndTotalCount(filter, seriesId));
+ Assert.Equal((1, 1), _service.GetPlayedAndTotalCountBatch([seriesId], user)[seriesId]);
+ }
+
+ [Fact]
+ public void GetCounts_MultiVersionMovie_CountPlaybackOfAnyVersion()
+ {
+ // Two movies held as two files each: the primary the collection links, and an alternate version
+ // linked to it. One movie was watched on its alternate, which is where playback of a second cut
+ // lands; the other was not watched at all.
+ var user = new User("alt-version-test", "provider", "reset");
+ var boxSetId = Guid.NewGuid();
+ var libraryId = Guid.NewGuid();
+ var watchedPrimaryId = Guid.NewGuid();
+ var watchedAlternateId = Guid.NewGuid();
+ var unwatchedPrimaryId = Guid.NewGuid();
+ var unwatchedAlternateId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ var boxSet = CreateItem(boxSetId);
+ boxSet.PresentationUniqueKey = "alt-version-box-set";
+ context.BaseItems.Add(boxSet);
+
+ var library = CreateItem(libraryId);
+ library.PresentationUniqueKey = "alt-version-library";
+ context.BaseItems.Add(library);
+
+ foreach (var (primaryId, alternateId) in
+ new[] { (watchedPrimaryId, watchedAlternateId), (unwatchedPrimaryId, unwatchedAlternateId) })
+ {
+ context.BaseItems.Add(CreateLeaf(primaryId));
+
+ var alternate = CreateLeaf(alternateId);
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+ }
+
+ context.SaveChanges();
+
+ context.LinkedChildren.AddRange(
+ new LinkedChildEntity
+ {
+ ParentId = boxSetId,
+ ChildId = watchedPrimaryId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = boxSetId,
+ ChildId = unwatchedPrimaryId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 1
+ },
+ new LinkedChildEntity
+ {
+ ParentId = watchedPrimaryId,
+ ChildId = watchedAlternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = unwatchedPrimaryId,
+ ChildId = unwatchedAlternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ AddAncestor(context, watchedPrimaryId, libraryId);
+ AddAncestor(context, unwatchedPrimaryId, libraryId);
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = watchedAlternateId,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ // A version group is one item to count, and the alternate's playback makes that item played -
+ // as it already does for the played flag the primary itself reports.
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCountFromLinkedChildren(filter, boxSetId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCountBatch([boxSetId], user)[boxSetId]);
+
+ // The ancestor-based paths answer the same for the library the primaries sit in.
+ Assert.Equal(1, _service.GetPlayedCount(filter, libraryId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, libraryId));
+ }
+
+ [Fact]
+ public void GetChildCountBatch_NoUser_StillCollapsesAlternateVersions()
+ {
+ // Both files of a merged movie sit in the folder. With a user it is access filtering that
+ // drops the alternate; with no user nothing else would, and the folder would report two
+ // children for the one title a viewer sees.
+ var folderId = Guid.NewGuid();
+ var primaryId = Guid.NewGuid();
+ var alternateId = Guid.NewGuid();
+ var extraId = Guid.NewGuid();
+ var ownedId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(CreateItem(folderId));
+
+ var primary = CreateLeaf(primaryId);
+ primary.ParentId = folderId;
+ context.BaseItems.Add(primary);
+
+ var alternate = CreateLeaf(alternateId);
+ alternate.ParentId = folderId;
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+
+ // An extra carries an owner and an extra type, and stays a child of its own.
+ var extra = CreateLeaf(extraId);
+ extra.ParentId = folderId;
+ extra.OwnerId = primaryId;
+ extra.ExtraType = BaseItemExtraType.Trailer;
+ context.BaseItems.Add(extra);
+
+ // An owned item that is not an extra belongs to its owner, not to the folder.
+ var owned = CreateLeaf(ownedId);
+ owned.ParentId = folderId;
+ owned.OwnerId = primaryId;
+ context.BaseItems.Add(owned);
+
+ context.SaveChanges();
+ }
+
+ Assert.Equal(2, _service.GetChildCountBatch([folderId], null)[folderId]);
+ }
+
+ [Fact]
public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys()
{
var seriesA = Guid.NewGuid();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs
new file mode 100644
index 0000000000..c15ea09965
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the invariant that a video linked as an alternate version also carries the
+/// PrimaryVersionId the item queries hide it by, including when it was already a library
+/// item in its own right before it became a version.
+/// </summary>
+public sealed class ItemPersistenceAlternateVersionTests : SqliteDbTestFixture
+{
+ private const string PrimaryPath = "/movies/Movie/Movie - 4K.mkv";
+ private const string VersionPath = "/movies/Movie/Movie - 1080p.mkv";
+
+ private readonly ItemPersistenceService _service;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IServerConfigurationManager? _previousConfigurationManager;
+ private readonly IRecordingsManager? _previousRecordingsManager;
+
+ public ItemPersistenceAlternateVersionTests()
+ {
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousConfigurationManager = BaseItem.ConfigurationManager;
+ _previousRecordingsManager = Video.RecordingsManager;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>()))
+ .Returns([]);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configurationManager.Object;
+
+ // Video.SourceType asks this whether the file is an in-progress recording.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
+ // Paths round-trip through the host's virtual path mapping on the way in and out.
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(h => h.ReverseVirtualPath(It.IsAny<string>())).Returns((string p) => p);
+ appHost.Setup(h => h.ExpandVirtualPath(It.IsAny<string>())).Returns((string p) => p);
+
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ appHost.Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ Video.RecordingsManager = _previousRecordingsManager!;
+ base.Dispose(disposing);
+ }
+
+ [Fact]
+ public void SaveItems_LocalAlternateVersionAlreadyAnItem_SetsPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ // The version was scanned as a standalone movie before it became a version, so it has a
+ // presentation key of its own and no PrimaryVersionId.
+ var version = CreateMovie(versionId, VersionPath);
+ version.PresentationUniqueKey = "standalone";
+ _service.SaveItems([version], CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId);
+ }
+
+ // Now the scan folds it into a primary, which is the item that gets saved.
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId)));
+ Assert.Equal(DbLinkedChildType.LocalAlternateVersion, link.ChildType);
+ Assert.Equal(versionId, link.ChildId);
+
+ var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId));
+ Assert.Equal(primaryId, stored.PrimaryVersionId);
+
+ // Presentation-key grouping has to collapse it onto the primary as well.
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey);
+ }
+ }
+
+ [Fact]
+ public void SaveItems_LinkedAlternateVersionAlreadyAnItem_SetsPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ _service.SaveItems([CreateMovie(versionId, VersionPath)], CancellationToken.None);
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LinkedAlternateVersions =
+ [
+ new LinkedChild { ItemId = versionId, Type = LinkedChildType.LinkedAlternateVersion }
+ ];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId)));
+ Assert.Equal(DbLinkedChildType.LinkedAlternateVersion, link.ChildType);
+ Assert.Equal(primaryId, ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId);
+ }
+
+ [Fact]
+ public void SaveItems_VersionAlreadyPointingAtPrimary_LeavesItAlone()
+ {
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
+
+ var version = CreateMovie(versionId, VersionPath);
+ version.SetPrimaryVersionId(primaryId);
+ _service.SaveItems([version], CancellationToken.None);
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId));
+ Assert.Equal(primaryId, stored.PrimaryVersionId);
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey);
+ }
+
+ [Fact]
+ public void SaveItems_VideoListedAmongItsOwnVersions_KeepsItsOwnPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [PrimaryPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(primaryId)).PrimaryVersionId);
+ }
+
+ [Fact]
+ public void SaveItems_PromotedVersionStillPointingAtOldPrimary_DoesNotCreateACycle()
+ {
+ var promotedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var oldPrimaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+
+ _service.SaveItems([CreateMovie(oldPrimaryId, VersionPath)], CancellationToken.None);
+
+ // The rescan resolves this one as the primary of the group, but it still carries the pointer
+ // to the version it was promoted over.
+ var promoted = CreateMovie(promotedId, PrimaryPath);
+ promoted.SetPrimaryVersionId(oldPrimaryId);
+ promoted.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([promoted], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+
+ // Pointing the old primary back would hide both, and with them the whole group.
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(oldPrimaryId)).PrimaryVersionId);
+ Assert.Equal(oldPrimaryId, ctx.BaseItems.First(e => e.Id.Equals(promotedId)).PrimaryVersionId);
+ }
+
+ private static Movie CreateMovie(Guid id, string path) => new()
+ {
+ Id = id,
+ Name = "Movie",
+ Path = path
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs
new file mode 100644
index 0000000000..e2bdd9e0b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// DeleteItem has to hand SQLite one statement that already contains everything the foreign keys
+/// on BaseItems require, because FK_BaseItems_BaseItems_OwnerId is NO ACTION: anything left behind
+/// pointing at a deleted row fails the whole delete with SQLite error 19.
+/// </summary>
+public sealed class ItemPersistenceDeleteItemTests : SqliteDbTestFixture
+{
+ private static readonly Guid _owner = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
+ private static readonly Guid _extra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001");
+ private static readonly Guid _extraOfExtra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000002");
+ private static readonly Guid _child = Guid.Parse("cccccccc-0000-0000-0000-000000000001");
+ private static readonly Guid _extraOfChild = Guid.Parse("eeeeeeee-0000-0000-0000-000000000003");
+
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistenceDeleteItemTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ new Mock<IServerApplicationHost>().Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_OwnerIdChain_DeletesWholeChain()
+ {
+ // An extra that owns an extra of its own. Real libraries carry these in bulk, and a single
+ // expansion pass over OwnerId leaves the second level behind.
+ Seed(
+ (_owner, null, null),
+ (_extra, _owner, null),
+ (_extraOfExtra, _extra, null));
+
+ _service.DeleteItem([_owner]);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra) || e.Id.Equals(_extraOfExtra)));
+ }
+
+ [Fact]
+ public void DeleteItem_ExtraOwnedByCascadedChild_DeletesExtraToo()
+ {
+ // The child goes away through FK_BaseItems_BaseItems_ParentId's ON DELETE CASCADE whether or
+ // not it is listed, so an extra owned by that child has to be listed with it.
+ Seed(
+ (_owner, null, null),
+ (_child, null, _owner),
+ (_extraOfChild, _child, null));
+
+ _service.DeleteItem([_owner]);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_child) || e.Id.Equals(_extraOfChild)));
+ }
+
+ [Fact]
+ public void DeleteItem_OwnershipCycle_Terminates()
+ {
+ // A malformed pair that owns each other must not spin the closure loop forever.
+ Seed((_owner, null, null), (_extra, _owner, null));
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Single(e => e.Id.Equals(_owner)).OwnerId = _extra;
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([_owner]);
+
+ using var assertContext = CreateDbContext();
+ Assert.Empty(assertContext.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra)));
+ }
+
+ private void Seed(params (Guid Id, Guid? OwnerId, Guid? ParentId)[] items)
+ {
+ using var context = CreateDbContext();
+
+ // Owners before the rows referencing them: the seed itself is foreign key checked.
+ foreach (var (id, ownerId, parentId) in items)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = "MediaBrowser.Controller.Entities.Video",
+ OwnerId = ownerId,
+ ParentId = parentId
+ });
+
+ context.SaveChanges();
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
index 82614c3156..fea6ccb7a1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
@@ -2,12 +2,16 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging.Abstractions;
@@ -21,12 +25,15 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
private readonly ItemPersistenceService _service;
private readonly ILibraryManager? _previousLibraryManager;
private readonly IServerConfigurationManager? _previousConfigurationManager;
+ private readonly IRecordingsManager? _previousRecordingsManager;
+ private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111");
public ItemPersistenceOwnedRowTests()
{
// BaseItem resolves these through process-wide statics; restored in Dispose.
_previousLibraryManager = BaseItem.LibraryManager;
_previousConfigurationManager = BaseItem.ConfigurationManager;
+ _previousRecordingsManager = Video.RecordingsManager;
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>()))
@@ -37,6 +44,9 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
BaseItem.ConfigurationManager = configurationManager.Object;
+ // Video.SourceType consults this before it can produce user data keys.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
_service = new ItemPersistenceService(
CreateDbContextFactory(),
new Mock<IServerApplicationHost>().Object,
@@ -47,6 +57,7 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
{
BaseItem.LibraryManager = _previousLibraryManager!;
BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ Video.RecordingsManager = _previousRecordingsManager!;
base.Dispose(disposing);
}
@@ -102,6 +113,99 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue);
}
+ [Fact]
+ public async Task ReattachUserData_DetachedRowsFromDifferentEras_CollapsesToMostRecentPlay()
+ {
+ var movie = CreateMovie(Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"));
+ var keys = movie.GetUserDataKeys();
+ SeedUserDataItem(movie);
+
+ using (var ctx = CreateDbContext())
+ {
+ // The guid-keyed row was detached by an older deletion than the provider-keyed ones.
+ ctx.UserData.AddRange(
+ CreateDetachedRow(keys[^1], new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490),
+ CreateDetachedRow(keys[0], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true),
+ CreateDetachedRow(keys[1], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true));
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList();
+
+ Assert.Equal(keys.Count, rows.Count);
+ Assert.Equal(keys.OrderBy(e => e, StringComparer.Ordinal), rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal));
+ Assert.All(rows, row =>
+ {
+ Assert.True(row.Played);
+ Assert.Equal(0, row.PlaybackPositionTicks);
+ Assert.Equal(9, row.PlayCount);
+ Assert.Null(row.RetentionDate);
+ });
+
+ Assert.Empty(ctx.UserData.Where(e => e.ItemId.Equals(BaseItemRepository.PlaceholderId)));
+ }
+ }
+
+ [Fact]
+ public async Task ReattachUserData_ExistingRowUnderUnreportedKey_IsKeptInAgreement()
+ {
+ var movie = CreateMovie(Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"));
+ var keys = movie.GetUserDataKeys();
+ const string UnreportedKey = "tvdb-key-missing-mid-refresh";
+ SeedUserDataItem(movie);
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.UserData.AddRange(
+ CreateRow(movie.Id, UnreportedKey, new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123),
+ CreateDetachedRow(keys[0], new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 0, played: true));
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList();
+
+ Assert.Equal(
+ keys.Append(UnreportedKey).OrderBy(e => e, StringComparer.Ordinal),
+ rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal));
+ Assert.All(rows, row =>
+ {
+ Assert.True(row.Played);
+ Assert.Equal(2, row.PlayCount);
+ Assert.Equal(0, row.PlaybackPositionTicks);
+ });
+ }
+ }
+
+ [Fact]
+ public async Task ReattachUserData_NoDetachedRows_LeavesExistingRowsAlone()
+ {
+ var movie = CreateMovie(Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"));
+ var keys = movie.GetUserDataKeys();
+ SeedUserDataItem(movie);
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.UserData.Add(CreateRow(movie.Id, keys[0], new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123));
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var row = Assert.Single(ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)));
+ Assert.Equal(123, row.PlaybackPositionTicks);
+ }
+ }
+
private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields)
{
var book = new Book
@@ -115,4 +219,59 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
return book;
}
+
+ private static Movie CreateMovie(Guid id)
+ {
+ return new Movie
+ {
+ Id = id,
+ Name = "Black Widow",
+ ProviderIds = new Dictionary<string, string>
+ {
+ ["Tmdb"] = "497698",
+ ["Imdb"] = "tt3480822"
+ }
+ };
+ }
+
+ private void SeedUserDataItem(BaseItem item)
+ {
+ using var ctx = CreateDbContext();
+ if (!ctx.Users.Any(e => e.Id.Equals(_userId)))
+ {
+ ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId });
+ }
+
+ if (!ctx.BaseItems.Any(e => e.Id.Equals(BaseItemRepository.PlaceholderId)))
+ {
+ ctx.BaseItems.Add(new BaseItemEntity { Id = BaseItemRepository.PlaceholderId, Type = typeof(Folder).FullName! });
+ }
+
+ ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = item.GetType().FullName! });
+ ctx.SaveChanges();
+ }
+
+ private UserData CreateDetachedRow(string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false)
+ {
+ var row = CreateRow(BaseItemRepository.PlaceholderId, key, lastPlayed, playCount, positionTicks, played);
+ row.RetentionDate = new DateTime(2025, 6, 22, 0, 0, 0, DateTimeKind.Utc);
+
+ return row;
+ }
+
+ private UserData CreateRow(Guid itemId, string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false)
+ {
+ return new UserData
+ {
+ ItemId = itemId,
+ Item = null,
+ UserId = _userId,
+ User = null,
+ CustomDataKey = key,
+ LastPlayedDate = lastPlayed,
+ PlayCount = playCount,
+ PlaybackPositionTicks = positionTicks,
+ Played = played
+ };
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs
new file mode 100644
index 0000000000..fc28025573
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemPersistencePeopleCleanupTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistencePeopleCleanupTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_RemovesUnusedPeopleForItemsDescendantsAndExtras()
+ {
+ var parent = CreateItem(isFolder: true);
+ var child = CreateItem();
+ child.ParentId = parent.Id;
+ var extra = CreateItem();
+ extra.OwnerId = child.Id;
+ var survivor = CreateItem();
+ var shared = CreatePerson("Shared person");
+ var unrelatedOrphan = CreatePerson("Unrelated orphan");
+ using (var context = CreateDbContext())
+ {
+ context.PeopleBaseItemMap.AddRange(
+ Map(parent, CreatePerson("Parent credit")),
+ Map(child, CreatePerson("Child credit")),
+ Map(extra, CreatePerson("Extra credit")),
+ Map(child, shared),
+ Map(survivor, shared));
+ context.Peoples.Add(unrelatedOrphan);
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = child.Id,
+ Item = child,
+ ParentItemId = parent.Id,
+ ParentItem = parent
+ });
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([parent.Id]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id);
+ Assert.Equal(survivor.Id, Assert.Single(after.PeopleBaseItemMap).ItemId);
+ Assert.Equal(new[] { shared.Id, unrelatedOrphan.Id }.Order(), after.Peoples.Select(e => e.Id).Order());
+ }
+
+ private static BaseItemEntity CreateItem(bool isFolder = false) => new()
+ {
+ Id = Guid.NewGuid(),
+ Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!,
+ IsFolder = isFolder
+ };
+
+ private static People CreatePerson(string name) => new()
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ PersonType = "Actor"
+ };
+
+ private static PeopleBaseItemMap Map(BaseItemEntity item, People person) => new()
+ {
+ ItemId = item.Id,
+ Item = item,
+ PeopleId = person.Id,
+ People = person,
+ Role = string.Empty
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs
new file mode 100644
index 0000000000..cbfab403dc
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemValuesCleanupTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemValuesCleanupTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_LastReference_RemovesNewAndExistingOrphans()
+ {
+ var deleted = CreateItem();
+ var survivor = CreateItem();
+ var referenced = CreateValue("Referenced");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.AddRange(
+ Map(deleted, CreateValue("Last reference")),
+ Map(survivor, referenced));
+ context.ItemValues.Add(CreateValue("Already orphaned"));
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([deleted.Id]);
+
+ using var after = CreateDbContext();
+ Assert.False(after.BaseItems.Any(e => e.Id.Equals(deleted.Id)));
+ Assert.Equal(referenced.ItemValueId, Assert.Single(after.ItemValues).ItemValueId);
+ Assert.Equal(survivor.Id, Assert.Single(after.ItemValuesMap).ItemId);
+ }
+
+ [Fact]
+ public void DeleteItem_BatchWithDescendantAndOwnedExtra_CleansAllRemovedReferences()
+ {
+ var parent = CreateItem(isFolder: true);
+ var child = CreateItem();
+ child.ParentId = parent.Id;
+ var extra = CreateItem();
+ extra.OwnerId = child.Id;
+ var otherDeleted = CreateItem();
+ var survivor = CreateItem();
+ var batchShared = CreateValue("Shared inside deletion batch");
+ var survivingShared = CreateValue("Shared with surviving item");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.AddRange(
+ Map(parent, CreateValue("Parent value")),
+ Map(child, batchShared),
+ Map(otherDeleted, batchShared),
+ Map(extra, CreateValue("Extra value")),
+ Map(child, survivingShared),
+ Map(survivor, survivingShared));
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = child.Id,
+ Item = child,
+ ParentItemId = parent.Id,
+ ParentItem = parent
+ });
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([parent.Id, otherDeleted.Id]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(survivingShared.ItemValueId, Assert.Single(after.ItemValues).ItemValueId);
+ Assert.Equal(survivor.Id, Assert.Single(after.ItemValuesMap).ItemId);
+ Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id);
+ Assert.Empty(after.AncestorIds);
+ }
+
+ [Fact]
+ public void DeleteItem_ChildWithoutAncestorRows_CleansValueOrphanedByCascade()
+ {
+ var parent = CreateItem(isFolder: true);
+ var child = CreateItem();
+ child.ParentId = parent.Id;
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(parent);
+ context.ItemValuesMap.Add(Map(child, CreateValue("Cascaded child value")));
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([parent.Id]);
+
+ using var after = CreateDbContext();
+ Assert.False(after.BaseItems.Any(e => e.Id.Equals(child.Id)));
+ Assert.Empty(after.ItemValuesMap);
+ Assert.Empty(after.ItemValues);
+ }
+
+ [Fact]
+ public void DeleteItem_CleanupFails_RollsBackItemAndMapDeletion()
+ {
+ var item = CreateItem();
+ var value = CreateValue("Last reference");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.Add(Map(item, value));
+ context.ItemValues.Add(CreateValue("Already orphaned"));
+ context.SaveChanges();
+ context.Database.ExecuteSqlRaw("""
+ CREATE TRIGGER FailItemValuesCleanup BEFORE DELETE ON ItemValues
+ BEGIN
+ SELECT RAISE(ABORT, 'injected orphan cleanup failure');
+ END;
+ """);
+ }
+
+ var exception = Assert.Throws<SqliteException>(() => _service.DeleteItem([item.Id]));
+
+ Assert.Contains("injected orphan cleanup failure", exception.Message, StringComparison.Ordinal);
+ using var after = CreateDbContext();
+ Assert.True(after.BaseItems.Any(e => e.Id.Equals(item.Id)));
+ Assert.Equal(value.ItemValueId, Assert.Single(after.ItemValuesMap).ItemValueId);
+ Assert.Equal(2, after.ItemValues.Count());
+ }
+
+ [Fact]
+ public async Task PostScanRun_NoDeadItems_RemovesUnrelatedOrphansAndPreservesReferences()
+ {
+ var item = CreateItem();
+ var referenced = CreateValue("Referenced");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.Add(Map(item, referenced));
+ context.ItemValues.Add(CreateValue("Unrelated orphan"));
+ await context.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var library = new Mock<ILibraryManager>(MockBehavior.Strict);
+ library.Setup(e => e.GetItemIds(It.Is<InternalItemsQuery>(query => query.HasDeadParentId == true)))
+ .Returns(Array.Empty<Guid>());
+ library.Setup(e => e.GetItemList(It.IsAny<InternalItemsQuery>())).Returns(Array.Empty<BaseItem>());
+ var task = new CleanDatabaseScheduledTask(
+ library.Object,
+ NullLogger<CleanDatabaseScheduledTask>.Instance,
+ CreateDbContextFactory(),
+ Mock.Of<IPathManager>(MockBehavior.Strict));
+
+ await task.Run(Mock.Of<IProgress<double>>(), TestContext.Current.CancellationToken);
+
+ using var after = CreateDbContext();
+ Assert.Equal(referenced.ItemValueId, Assert.Single(after.ItemValues).ItemValueId);
+ Assert.Equal(item.Id, Assert.Single(after.ItemValuesMap).ItemId);
+ Assert.True(after.BaseItems.Any(e => e.Id.Equals(item.Id)));
+ }
+
+ private static BaseItemEntity CreateItem(bool isFolder = false) => new()
+ {
+ Id = Guid.NewGuid(),
+ Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!,
+ IsFolder = isFolder
+ };
+
+ private static ItemValue CreateValue(string value) => new()
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = value,
+ CleanValue = value.ToLowerInvariant()
+ };
+
+ private static ItemValueMap Map(BaseItemEntity item, ItemValue value) => new()
+ {
+ ItemId = item.Id,
+ Item = item,
+ ItemValueId = value.ItemValueId,
+ ItemValue = value
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
new file mode 100644
index 0000000000..8ed3c61a59
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one
+/// whose alternate carries the played row, so an episode already seen must not be offered again.
+/// </summary>
+public sealed class NextUpServiceTests : SqliteDbTestFixture
+{
+ private const string SeriesKey = "next-up-series";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+
+ private readonly NextUpService _service;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ private readonly Guid _playedViaAlternate = Guid.NewGuid();
+ private readonly Guid _unplayed = Guid.NewGuid();
+
+ public NextUpServiceTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ _service = new NextUpService(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ CreateBaseItemRepository(itemTypeLookup));
+ }
+
+ [Fact]
+ public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt()
+ {
+ var batch = _service.GetNextUpEpisodesBatch(
+ new InternalItemsQuery(_user),
+ [SeriesKey],
+ includeSpecials: false,
+ includeWatchedForRewatching: false)[SeriesKey];
+
+ Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id);
+ Assert.Equal(_unplayed, batch.NextUp?.Id);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.Users.Add(_user);
+
+ AddEpisode(context, _playedViaAlternate, 1);
+ AddEpisode(context, _unplayed, 2);
+
+ // The second file of the first episode, and the only row the playback was recorded against.
+ // It presents under its primary's key, which is what keeps it out of the candidate list.
+ var alternateId = Guid.NewGuid();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = alternateId,
+ Type = EpisodeType,
+ Name = "Episode 1 4K",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = 1,
+ PresentationUniqueKey = _playedViaAlternate.ToString("N"),
+ PrimaryVersionId = _playedViaAlternate
+ });
+
+ context.SaveChanges();
+
+ // The link the scanner writes alongside PrimaryVersionId, and the hop the played state
+ // reaches the alternate through.
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _playedViaAlternate,
+ ChildId = alternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = alternateId,
+ UserId = _user.Id,
+ CustomDataKey = alternateId.ToString("N"),
+ Played = true,
+ Item = null!,
+ User = null!
+ });
+
+ context.SaveChanges();
+ }
+
+ private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = EpisodeType,
+ Name = $"Episode {indexNumber}",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = indexNumber,
+ PresentationUniqueKey = id.ToString("N")
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs
new file mode 100644
index 0000000000..f143ce1e20
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryMustHaveItemTests.cs
@@ -0,0 +1,85 @@
+using System;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers <see cref="InternalPeopleQuery.MustHaveItem"/>. A caller that hands back by-name items can
+/// only return the people that have one, so the count it reports has to leave out the rest rather than
+/// describe a larger set than it can page through.
+/// </summary>
+public sealed class PeopleRepositoryMustHaveItemTests : SqliteDbTestFixture
+{
+ private readonly PeopleRepository _people;
+ private readonly Guid _movie = Guid.NewGuid();
+
+ public PeopleRepositoryMustHaveItemTests()
+ {
+ var lookup = new ItemTypeLookup();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _movie,
+ Name = "Movie",
+ Type = lookup.BaseItemKindNames[BaseItemKind.Movie]
+ });
+
+ // Only one of the two credits has a by-name item behind it.
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = "With Item",
+ Type = lookup.BaseItemKindNames[BaseItemKind.Person]
+ });
+
+ context.SaveChanges();
+ }
+
+ _people = new PeopleRepository(CreateDbContextFactory(), lookup, Mock.Of<IItemQueryHelpers>());
+ _people.UpdatePeople(_movie, [
+ new PersonInfo { Name = "With Item", Type = PersonKind.Actor },
+ new PersonInfo { Name = "Without Item", Type = PersonKind.Actor }
+ ]);
+ }
+
+ [Fact]
+ public void WithoutMustHaveItem_ReturnsAndCountsEveryCredit()
+ {
+ var result = _people.GetPeople(new InternalPeopleQuery());
+
+ Assert.Equal(2, result.TotalRecordCount);
+ Assert.Equal(2, result.Items.Count);
+ }
+
+ [Fact]
+ public void MustHaveItem_DropsTheCreditWithoutAnItem()
+ {
+ var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true });
+
+ Assert.Equal("With Item", Assert.Single(result.Items).Name);
+ }
+
+ [Fact]
+ public void MustHaveItem_CountsOnlyWhatItCanReturn()
+ {
+ var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true });
+
+ Assert.Equal(1, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void MustHaveItem_CountAgreesWithThePageWhenLimited()
+ {
+ var result = _people.GetPeople(new InternalPeopleQuery { MustHaveItem = true, Limit = 10 });
+
+ Assert.Equal(result.Items.Count, result.TotalRecordCount);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryNameRangeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryNameRangeTests.cs
new file mode 100644
index 0000000000..754b388ecf
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryNameRangeTests.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// The letter picker on the person list filters with <c>NameStartsWithOrGreater</c> and <c>NameLessThan</c>.
+/// Names keep the case a metadata provider wrote them in, so the range has to ignore case the same way
+/// the list is sorted, or a capitalised name lands before every lowercase bound.
+/// </summary>
+public sealed class PeopleRepositoryNameRangeTests : SqliteDbTestFixture
+{
+ private readonly PeopleRepository _repository;
+
+ public PeopleRepositoryNameRangeTests()
+ {
+ using (var context = CreateDbContext())
+ {
+ foreach (var name in new[] { "50 Cent", "alpha centauri", "Brad Pitt", "bob dylan", "Zoe Saldana" })
+ {
+ context.Peoples.Add(new People { Id = Guid.NewGuid(), Name = name, PersonType = "Actor" });
+ }
+
+ context.SaveChanges();
+ }
+
+ _repository = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), new Mock<IItemQueryHelpers>().Object);
+ }
+
+ [Theory]
+ [InlineData(null, "A", new[] { "50 Cent" })]
+ [InlineData(null, "C", new[] { "50 Cent", "alpha centauri", "bob dylan", "Brad Pitt" })]
+ [InlineData("B", "C", new[] { "bob dylan", "Brad Pitt" })]
+ [InlineData("Y", null, new[] { "Zoe Saldana" })]
+ public void GetPeople_NameRange_IgnoresCase(string? nameStartsWithOrGreater, string? nameLessThan, string[] expected)
+ {
+ var result = _repository.GetPeople(new InternalPeopleQuery
+ {
+ NameStartsWithOrGreater = nameStartsWithOrGreater,
+ NameLessThan = nameLessThan
+ });
+
+ Assert.Equal(expected, result.Items.Select(p => p.Name));
+ Assert.Equal(expected.Length, result.TotalRecordCount);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs
new file mode 100644
index 0000000000..b925f98197
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class PeopleUpdateQueryTests : SqliteDbTestFixture
+{
+ private readonly CommandRecorder _recorder;
+ private readonly Guid _itemId = Guid.NewGuid();
+ private readonly PeopleRepository _people;
+
+ public PeopleUpdateQueryTests()
+ : this(new CommandRecorder())
+ {
+ }
+
+ private PeopleUpdateQueryTests(CommandRecorder recorder)
+ : base(recorder)
+ {
+ _recorder = recorder;
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _itemId,
+ Name = "Movie",
+ Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie]
+ });
+ context.SaveChanges();
+ _people = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), Mock.Of<IItemQueryHelpers>());
+ }
+
+ [Theory]
+ [InlineData("Hero")]
+ [InlineData("HERO")]
+ public void UnchangedCredits_DoNotWriteOrLookUpAllPeople(string role)
+ {
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, Role = "Hero" }]);
+ _recorder.Commands.Clear();
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "actor", Type = PersonKind.Actor, Role = role }]);
+ Assert.Single(_recorder.Commands);
+ Assert.StartsWith("SELECT", _recorder.Commands[0].Sql, StringComparison.Ordinal);
+ using var context = CreateDbContext();
+ Assert.Equal("Hero", Assert.Single(context.PeopleBaseItemMap).Role);
+ }
+
+ [Fact]
+ public void SortOrderChange_IsPersisted()
+ {
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 1 }]);
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 2 }]);
+ using var context = CreateDbContext();
+ Assert.Equal(2, Assert.Single(context.PeopleBaseItemMap).SortOrder);
+ }
+
+ [Fact]
+ public void UpdatePeople_GeneratedSqlUsesPeopleNameIndex()
+ {
+ ApplyMigration(new Jellyfin.Server.Implementations.Migrations.AddPeopleNameLowerIndex());
+ _recorder.Commands.Clear();
+ _people.UpdatePeople(_itemId, [
+ new PersonInfo { Name = "Actor A", Type = PersonKind.Actor },
+ new PersonInfo { Name = "Actor B", Type = PersonKind.Actor }
+ ]);
+ var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("lower(\"p\".\"Name\")", StringComparison.Ordinal));
+ Assert.Contains(Explain(query), line => line.Contains("SEARCH p USING INDEX IX_Peoples_NameLower", StringComparison.Ordinal));
+ }
+
+ private void ApplyMigration(Migration migration)
+ {
+ using var context = CreateDbContext();
+ foreach (var operation in migration.UpOperations.Cast<SqlOperation>())
+ {
+ context.Database.ExecuteSqlRaw(operation.Sql);
+ }
+ }
+
+ private string[] Explain(RecordedCommand query)
+ {
+ using var context = CreateDbContext();
+ using var command = context.Database.GetDbConnection().CreateCommand();
+#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters.
+ command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql;
+#pragma warning restore CA2100
+ foreach (var value in query.Parameters)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = value.Name;
+ parameter.Value = value.Value;
+ command.Parameters.Add(parameter);
+ }
+
+ using var reader = command.ExecuteReader();
+ var plan = new List<string>();
+ while (reader.Read())
+ {
+ plan.Add(reader.GetString(3));
+ }
+
+ return plan.ToArray();
+ }
+
+ private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters);
+
+ private sealed class CommandRecorder : DbCommandInterceptor
+ {
+ public List<RecordedCommand> Commands { get; } = [];
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ private void Record(DbCommand command) => Commands.Add(new RecordedCommand(
+ command.CommandText,
+ command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
index cfc9c9496c..6da176b4f1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -26,7 +27,7 @@ public abstract class SqliteDbTestFixture : IDisposable
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
- protected SqliteDbTestFixture()
+ protected SqliteDbTestFixture(params IInterceptor[] interceptors)
{
ApplicationPaths = new Mock<IApplicationPaths>().Object;
@@ -35,6 +36,7 @@ public abstract class SqliteDbTestFixture : IDisposable
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
+ .AddInterceptors(interceptors)
.Options;
using var context = CreateDbContext();