aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs26
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs134
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs74
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs173
-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/Library/LibraryManager/FindExtrasTests.cs68
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs89
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs100
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs2
10 files changed, 975 insertions, 6 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs
new file mode 100644
index 0000000000..a60622902b
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Data/ItemTypeLookupTests.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Data;
+
+public class ItemTypeLookupTests
+{
+ public static TheoryData<BaseItemKind> MappedKinds()
+ => new(new ItemTypeLookup().BaseItemKindNames.Keys);
+
+ [Theory]
+ [MemberData(nameof(MappedKinds))]
+ public void BaseItemKindNames_Kind_NamesAnItemType(BaseItemKind kind)
+ {
+ var name = new ItemTypeLookup().BaseItemKindNames[kind];
+
+ var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType(name)).FirstOrDefault(t => t is not null);
+
+ Assert.NotNull(type);
+ Assert.True(typeof(BaseItem).IsAssignableFrom(type), $"{name} is not an item type.");
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs
new file mode 100644
index 0000000000..153e1dc154
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteDatabaseStatisticsTests.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Tests.Item;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Data;
+
+/// <summary>
+/// Statistics taken on a freshly created database describe every table as a single row, and SQLite then plans
+/// the user data and series queries of a filled library as full scans (#17886).
+/// </summary>
+public sealed class SqliteDatabaseStatisticsTests : SqliteDbTestFixture
+{
+ private readonly SqliteDatabaseProvider _provider;
+
+ public SqliteDatabaseStatisticsTests()
+ {
+ _provider = new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance)
+ {
+ DbContextFactory = CreateDbContextFactory()
+ };
+ }
+
+ [Fact]
+ public async Task RunScheduledOptimisation_EmptyLibrary_RecordsNoStatistics()
+ {
+ SeedFolders(3);
+
+ await _provider.RunScheduledOptimisation(CancellationToken.None);
+
+ Assert.Null(ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RunScheduledOptimisation_LibraryWithItems_RecordsStatistics()
+ {
+ SeedFolders(1);
+ SeedEpisodes(4);
+
+ await _provider.RunScheduledOptimisation(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_NoStatistics_Analyzes()
+ {
+ SeedEpisodes(5);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_LibraryChanged_Reanalyzes()
+ {
+ SeedEpisodes(10);
+ Analyze();
+ SeedEpisodes(5);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Equal(CountItems(), ReadAnalyzedItemCount());
+ }
+
+ [Fact]
+ public async Task RefreshStatistics_EmptyLibrary_RecordsNoStatistics()
+ {
+ SeedFolders(2);
+
+ await _provider.RefreshStatistics(CancellationToken.None);
+
+ Assert.Null(ReadAnalyzedItemCount());
+ }
+
+ private void SeedFolders(int count)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.Folder",
+ IsFolder = true
+ }));
+ context.SaveChanges();
+ }
+
+ private void SeedEpisodes(int count)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.AddRange(Enumerable.Range(0, count).Select(_ => new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.TV.Episode",
+ IsFolder = false
+ }));
+ context.SaveChanges();
+ }
+
+ private void Analyze()
+ {
+ using var context = CreateDbContext();
+ context.Database.ExecuteSqlRaw("ANALYZE");
+ }
+
+ private long CountItems()
+ {
+ using var context = CreateDbContext();
+ return context.BaseItems.LongCount();
+ }
+
+ private long? ReadAnalyzedItemCount()
+ {
+ using var context = CreateDbContext();
+ var hasStatistics = context.Database
+ .SqlQueryRaw<long>("SELECT count(*) AS \"Value\" FROM sqlite_schema WHERE type = 'table' AND name = 'sqlite_stat1'")
+ .Single();
+ if (hasStatistics == 0)
+ {
+ return null;
+ }
+
+ return context.Database
+ .SqlQueryRaw<long?>("SELECT max(CAST(stat AS INTEGER)) AS \"Value\" FROM sqlite_stat1 WHERE tbl = 'BaseItems'")
+ .Single();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs
new file mode 100644
index 0000000000..b88296a06a
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Images/CollectionFolderImageProviderTests.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Images;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Querying;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Images;
+
+/// <summary>
+/// A music library is collaged from its artists' backdrops. Artists are by-name items with no
+/// library of their own, so they have to be asked for through the by-name listing, which reaches
+/// them through the tracks that credit them; an item query for them ignores the library scope and
+/// hands back the artists of every music library.
+/// </summary>
+public sealed class CollectionFolderImageProviderTests
+{
+ [Fact]
+ public void GetItemsWithImages_MusicLibrary_AsksForTheArtistsOfThatLibraryOnly()
+ {
+ var view = new CollectionFolder { Id = Guid.NewGuid(), CollectionType = CollectionType.music };
+ var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "Artist" };
+
+ InternalItemsQuery? query = null;
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(l => l.GetAllArtists(It.IsAny<InternalItemsQuery>()))
+ .Callback<InternalItemsQuery>(q => query = q)
+ .Returns(new QueryResult<(BaseItem Item, ItemCounts ItemCounts)>([(artist, new ItemCounts())]));
+
+ var items = CreateProvider(libraryManager.Object).GetItems(view);
+
+ Assert.Equal([artist], items);
+ Assert.NotNull(query);
+ Assert.Equal([view.Id], query.AncestorIds);
+ Assert.Equal([ImageType.Primary], query.ImageTypes);
+ Assert.Equal(8, query.Limit);
+ }
+
+ private static TestableCollectionFolderImageProvider CreateProvider(ILibraryManager libraryManager)
+ {
+ return new TestableCollectionFolderImageProvider(
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IProviderManager>(),
+ Mock.Of<IApplicationPaths>(),
+ Mock.Of<IImageProcessor>(),
+ libraryManager);
+ }
+
+ private sealed class TestableCollectionFolderImageProvider : CollectionFolderImageProvider
+ {
+ public TestableCollectionFolderImageProvider(
+ IFileSystem fileSystem,
+ IProviderManager providerManager,
+ IApplicationPaths applicationPaths,
+ IImageProcessor imageProcessor,
+ ILibraryManager libraryManager)
+ : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager)
+ {
+ }
+
+ public IReadOnlyList<BaseItem> GetItems(BaseItem item) => GetItemsWithImages(item);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
new file mode 100644
index 0000000000..0c1d453293
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ArtistNameLookupTests.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Moq;
+using Xunit;
+using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ArtistNameLookupTests : SqliteDbTestFixture
+{
+ private readonly CommandRecorder _recorder;
+
+ public ArtistNameLookupTests()
+ : this(new CommandRecorder())
+ {
+ }
+
+ private ArtistNameLookupTests(CommandRecorder recorder)
+ : base(recorder)
+ {
+ _recorder = recorder;
+ }
+
+ [Theory]
+ [InlineData("Björk", "bjork")]
+ [InlineData("AC/DC", "ac dc")]
+ [InlineData("An Artist", " AN ARTIST ")]
+ [InlineData("Artist", "ARTIST")]
+ public void GetArtist_UsesSameNormalizedNameAsFindArtists(string storedName, string requestedName)
+ {
+ var lookup = new ItemTypeLookup();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = artistId,
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = storedName,
+ CleanName = storedName.GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.Person]
+ });
+ context.SaveChanges();
+ }
+
+ var manager = CreateLibraryManager(lookup);
+ _recorder.Commands.Clear();
+
+ Assert.Equal(artistId, manager.GetArtist(requestedName).Id);
+ var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("\"CleanName\" =", StringComparison.Ordinal));
+ Assert.Contains(Explain(query), line => line.Contains("IX_BaseItems_Type_CleanName (Type=? AND CleanName=?)", StringComparison.Ordinal));
+ Assert.Equal(artistId, Assert.Single(manager.GetArtists([requestedName])[requestedName]).Id);
+ }
+
+ [Fact]
+ public void GetArtist_PrefersFilesystemArtistWhenNormalizedNamesMatch()
+ {
+ var lookup = new ItemTypeLookup();
+ var parentId = Guid.NewGuid();
+ var artistId = Guid.NewGuid();
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(
+ new BaseItemEntity
+ {
+ Id = parentId,
+ Name = "Music",
+ Type = lookup.BaseItemKindNames[BaseItemKind.Folder]
+ },
+ new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Name = "Bjork",
+ CleanName = "Bjork".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ },
+ new BaseItemEntity
+ {
+ Id = artistId,
+ ParentId = parentId,
+ Name = "Björk",
+ CleanName = "Björk".GetCleanValue(),
+ Type = lookup.BaseItemKindNames[BaseItemKind.MusicArtist]
+ });
+ context.SaveChanges();
+ }
+
+ Assert.Equal(artistId, CreateLibraryManager(lookup).GetArtist("Bjork").Id);
+ }
+
+ private ServerLibraryManager CreateLibraryManager(ItemTypeLookup lookup)
+ {
+ var repository = CreateBaseItemRepository(lookup);
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ fixture.Inject<IItemRepository>(repository);
+ fixture.Inject<ILinkedChildrenService>(new LinkedChildrenService(CreateDbContextFactory(), lookup, repository));
+ return fixture.Create<ServerLibraryManager>();
+ }
+
+ private string[] Explain(RecordedCommand query)
+ {
+ using var context = CreateDbContext();
+ using var command = context.Database.GetDbConnection().CreateCommand();
+#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters.
+ command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql;
+#pragma warning restore CA2100
+ foreach (var value in query.Parameters)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = value.Name;
+ parameter.Value = value.Value;
+ command.Parameters.Add(parameter);
+ }
+
+ using var reader = command.ExecuteReader();
+ var plan = new List<string>();
+ while (reader.Read())
+ {
+ plan.Add(reader.GetString(3));
+ }
+
+ return plan.ToArray();
+ }
+
+ private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters);
+
+ private sealed class CommandRecorder : DbCommandInterceptor
+ {
+ public List<RecordedCommand> Commands { get; } = [];
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ private void Record(DbCommand command) => Commands.Add(new RecordedCommand(
+ command.CommandText,
+ command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/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/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
index a28c1d6dfb..f717d9b665 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
@@ -29,6 +29,7 @@ public class FindExtrasTests
{
private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager;
private readonly Mock<IFileSystem> _fileSystemMock;
+ private readonly Mock<IItemRepository> _itemRepositoryMock;
public FindExtrasTests()
{
@@ -36,8 +37,8 @@ public class FindExtrasTests
fixture.Register(() => new NamingOptions());
var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
- var itemRepository = fixture.Freeze<Mock<IItemRepository>>();
- itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
+ _itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>();
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
_fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
_fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
@@ -482,6 +483,69 @@ public class FindExtrasTests
}
[Fact]
+ public void FindExtras_ExtraNamedByLocalMetadata_KeepsItsNameOnRescan()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var directoryService = new DirectoryService(_fileSystemMock.Object);
+ var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Trailer", extra.Name);
+
+ // A local metadata file gives the extra a title of its own, which the refresh persists
+ extra.Name = "Cannes Teaser";
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra);
+
+ var rescanned = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Cannes Teaser", rescanned.Name);
+ }
+
+ [Fact]
+ public void FindExtras_ExtraKeptItsGeneratedName_IsRenumberedOnRescan()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer2.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var directoryService = new DirectoryService(_fileSystemMock.Object);
+ var extra = Assert.Single(_libraryManager.FindExtras(owner, files, directoryService));
+ Assert.Equal("Trailer", extra.Name);
+ _itemRepositoryMock.Setup(i => i.RetrieveItem(extra.Id)).Returns(extra);
+
+ // A trailer sorting before the known one takes the first number, so the known one moves on
+ files.Add(new FileSystemMetadata
+ {
+ FullName = "/movies/Up (2009)/Up (2009)-trailer1.mkv",
+ IsDirectory = false
+ });
+
+ var rescanned = _libraryManager.FindExtras(owner, files, directoryService)
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ Assert.Equal("Trailer", rescanned["/movies/Up (2009)/Up (2009)-trailer1.mkv"]);
+ Assert.Equal("Trailer 2", rescanned["/movies/Up (2009)/Up (2009)-trailer2.mkv"]);
+ }
+
+ [Fact]
public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle()
{
var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
index e1346a8436..3aac3cdc43 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using Moq;
@@ -89,4 +90,92 @@ public class MovieResolverTests
Assert.All(result.Items, item => Assert.IsType<Movie>(item));
Assert.Single(((Video)result.Items[0]).LocalAlternateVersions);
}
+
+ [Fact]
+ public void AllExtrasTypesFolderNames_ContainsSampleSingularAndPlural()
+ {
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("sample"));
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("Sample"));
+ Assert.True(_namingOptions.AllExtrasTypesFolderNames.ContainsKey("samples"));
+ }
+
+ [Theory]
+ [InlineData("Sample")]
+ [InlineData("sample")]
+ [InlineData("SAMPLE")]
+ [InlineData("samples")]
+ public void ResolvePath_MovieFolderWithSampleSubfolder_ResolvesToMovie(string sampleDirName)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+ libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false);
+
+ var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+ var args = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ libraryManager.Object)
+ {
+ Parent = new Folder(),
+ CollectionType = CollectionType.movies,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)",
+ IsDirectory = true
+ },
+ FileSystemChildren = new[]
+ {
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv",
+ Name = "Outer Colony (2026).mkv"
+ },
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/" + sampleDirName,
+ Name = sampleDirName,
+ IsDirectory = true
+ }
+ }
+ };
+
+ Assert.IsType<Movie>(resolver.ResolvePath(args));
+ }
+
+ [Fact]
+ public void ResolvePath_MovieFolderWithRealSubfolder_DoesNotResolveToSingleMovie()
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+ libraryManager.Setup(m => m.IgnoreFile(It.IsAny<FileSystemMetadata>(), It.IsAny<BaseItem>())).Returns(false);
+
+ var resolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver);
+ var args = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ libraryManager.Object)
+ {
+ Parent = new Folder(),
+ CollectionType = CollectionType.movies,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)",
+ IsDirectory = true
+ },
+ FileSystemChildren = new[]
+ {
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Outer Colony (2026).mkv",
+ Name = "Outer Colony (2026).mkv"
+ },
+ new FileSystemMetadata
+ {
+ FullName = "/media/Outer Colony (2026)/Feature",
+ Name = "Feature",
+ IsDirectory = true
+ }
+ }
+ };
+
+ Assert.Null(resolver.ResolvePath(args));
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 93014e7244..4e57f32b57 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -6,6 +6,7 @@ using BitFaster.Caching;
using Emby.Server.Implementations.Localization;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -180,7 +181,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
await localizationManager.LoadAll();
var ratings = localizationManager.GetParentalRatings().ToList();
- Assert.Equal(24, ratings.Count);
+ Assert.Equal(34, ratings.Count);
var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal));
Assert.NotNull(fsk);
@@ -209,6 +210,37 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
[InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive
[InlineData("VM 18", "IT", 18, null)]
[InlineData("Vietato ai minori di 18 anni", "IT", 18, null)]
+ [InlineData("ATP", "AR", 0, null)]
+ [InlineData("SAM 13", "AR", 13, null)]
+ [InlineData("SAM 16", "AR", 16, null)]
+ [InlineData("SAM 18", "AR", 18, null)]
+ [InlineData("SAM13", "AR", 13, null)] // Written without a space
+ [InlineData("AR-SAM 16", "AR", 16, null)] // Country prefix stripped against the configured country
+ [InlineData("AR-SAM 13", "US", 13, null)] // Country prefix resolved via the separator fallback
+ [InlineData("AR-SAM 18", "US", 18, null)]
+ [InlineData("AR-SAM13", "US", 13, null)]
+ [InlineData("SAM 18 C", "AR", 1001, null)] // Condicionada, same as "C"
+ [InlineData("Interdit aux moins de 12 ans", "FR", 12, null)]
+ [InlineData("Interdit aux moins de 18 ans", "FR", 18, null)]
+ [InlineData("X 18+", "AU", 1000, 0)] // Official spelling of the Australian X rating
+ [InlineData("X18+", "AU", 1000, 0)]
+ [InlineData("FSK18", "DE", 18, null)] // Written without a space
+ [InlineData("ab 18", "DE", 18, null)] // Written as the minimum age
+ [InlineData("DE:ab 6", "DE", 6, null)]
+ [InlineData("–12", "FR", 12, null)] // The CNC writes its minimum ages with an en dash
+ [InlineData("–16", "FR", 16, null)]
+ [InlineData("–18", "FR", 18, null)]
+ [InlineData("SU", "ID", 0, null)] // Indonesian broadcast classifications (KPI)
+ [InlineData("P", "ID", 2, null)]
+ [InlineData("A", "ID", 7, null)]
+ [InlineData("R", "ID", 13, null)]
+ [InlineData("D", "ID", 18, null)]
+ [InlineData("D18+", "ID", 18, null)] // Written with the minimum age, as broadcast since 2016
+ [InlineData("R-BO", "ID", 13, null)] // Parental guidance does not change the age group
+ [InlineData("Dewasa", "ID", 18, null)]
+ [InlineData("ID-D", "ID", 18, null)] // TMDB style country prefix
+ [InlineData("ID-D", "US", 18, null)] // Country prefix resolved via the separator fallback
+ [InlineData("P", "US", 1000, null)] // Ambiguous outside Indonesia, preferred as the Portuguese "Pornográfico"
public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration()
@@ -253,6 +285,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
[InlineData("12", 12, null)]
[InlineData("42", 42, null)]
[InlineData("9999", 9999, null)]
+ // The French CNC writes minimum ages as "-12" ("not for under 12s"). Parsing that as -12 would
+ // put the item below every MaxParentalRatingScore and bypass parental control entirely.
+ [InlineData("-10", 10, null)]
+ [InlineData("-12", 12, null)]
+ [InlineData("-16", 16, null)]
+ [InlineData("-18", 18, null)]
public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" });
@@ -323,6 +361,62 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ // Providers list every spelling of a rating in a single field. Splitting such a list by its country
+ // prefix pairs the first entry's country with the last entry's rating, so it has to be split by '/' first.
+ [InlineData("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18", "de", 18, null)]
+ [InlineData("SE:15 / SE:15+ / SE:Från 15 år", "de", 15, null)]
+ [InlineData("FR:16 / US:12", "de", 16, null)] // The first entry that resolves wins
+ public async Task GetRatingScore_CountryPrefixedList_UsesFirstResolvingEntry(string value, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = countryCode });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Fact]
+ public async Task GetRatingScore_ResolvedCountryPrefixedList_DoesNotWarn()
+ {
+ var logger = new Mock<ILogger<LocalizationManager>>();
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object);
+ await localizationManager.LoadAll();
+
+ Assert.NotNull(localizationManager.GetRatingScore("DE:FSK 18 / DE:FSK-18 / DE:FSK18 / DE:18 / DE:ab 18"));
+
+ logger.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task GetRatingScore_ListWithoutKnownRating_WarnsOnce()
+ {
+ var logger = new Mock<ILogger<LocalizationManager>>();
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "de" }, logger.Object);
+ await localizationManager.LoadAll();
+
+ Assert.Null(localizationManager.GetRatingScore("DE:Unbekannt / DE:Unsinn"));
+
+ logger.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ [Theory]
[InlineData("-NO RATING SHOWN-")]
[InlineData(":NO RATING SHOWN:")]
public async Task GetRatingLevel_Split_Success(string value)
@@ -525,12 +619,12 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase));
}
- private LocalizationManager Setup(ServerConfiguration config)
+ private LocalizationManager Setup(ServerConfiguration config, ILogger<LocalizationManager>? logger = null)
{
var mockConfiguration = new Mock<IServerConfigurationManager>();
mockConfiguration.SetupGet(x => x.Configuration).Returns(config);
- return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>());
+ return new LocalizationManager(mockConfiguration.Object, logger ?? new NullLogger<LocalizationManager>());
}
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
index a07e79baa3..13081dcff9 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
@@ -34,7 +34,7 @@ public class PlayCommandQueueTests : IDisposable
/// folder, so the queue query cannot exclude it. Such an item has no media sources, and a
/// client that reaches it in the queue gets an error instead of the next track.
/// </summary>
- /// <returns><placeholder>A <see cref="Task"/> representing the asynchronous unit test.</placeholder></returns>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems()
{