diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 0044fcd4dc..f97cc7a0a0 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -4127,6 +4127,18 @@ namespace Emby.Server.Implementations.Library
return _itemRepository.GetQueryFiltersLegacy(query);
}
+ ///
+ public IReadOnlyList GetTagNames(InternalItemsQuery query)
+ {
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return _itemRepository.GetTagNames(query);
+ }
+
///
public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType)
{
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index b458bd90f3..b9ca45cc1c 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -178,16 +178,35 @@ public class FilterController : BaseJellyfinApiController
IsSeries = isSeries
};
+ var tagQuery = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = includeItemTypes,
+ DtoOptions = new DtoOptions
+ {
+ Fields = Array.Empty(),
+ EnableImages = false,
+ EnableUserData = false
+ },
+ IsAiring = isAiring,
+ IsMovie = isMovie,
+ IsSports = isSports,
+ IsKids = isKids,
+ IsNews = isNews,
+ IsSeries = isSeries
+ };
+
if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
{
var ancestorIds = parentItem is null ? Array.Empty() : new[] { parentItem.Id };
genreQuery.AncestorIds = ancestorIds;
streamLanguageQuery.AncestorIds = ancestorIds;
+ tagQuery.AncestorIds = ancestorIds;
}
else
{
genreQuery.Parent = parentItem;
streamLanguageQuery.Parent = parentItem;
+ tagQuery.Parent = parentItem;
}
if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season))
@@ -218,6 +237,8 @@ public class FilterController : BaseJellyfinApiController
}).ToArray();
}
+ filters.Tags = _libraryManager.GetTagNames(tagQuery);
+
if (includeItemTypes.Contains(BaseItemKind.Movie)
|| includeItemTypes.Contains(BaseItemKind.Series)
|| includeItemTypes.Contains(BaseItemKind.Season)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index cdc8744642..51ac146a6f 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -117,6 +117,35 @@ public sealed partial class BaseItemRepository
.ToArray();
}
+ ///
+ public IReadOnlyList GetTagNames(InternalItemsQuery filter)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+ PrepareFilterQuery(filter);
+
+ using var context = _dbProvider.CreateDbContext();
+ var baseQuery = PrepareItemQuery(context, filter);
+ baseQuery = TranslateQuery(baseQuery, context, filter);
+
+ var matchingItemIds = baseQuery.Select(e => e.Id);
+
+ // Project the join before grouping. Grouping over the ItemValue navigation instead makes EF
+ // re-resolve the aggregate as a correlated subquery per group, which is orders of magnitude slower.
+ return context.ItemValuesMap
+ .AsNoTracking()
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Tags)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
+ .GroupBy(iv => iv.CleanValue)
+ .Select(g => g.Min(iv => iv.Value)!)
+ .OrderBy(t => t)
+ .ToArray();
+ }
+
private string[] GetItemValueNames(IReadOnlyList itemValueTypes, IReadOnlyList withItemTypes, IReadOnlyList excludeItemTypes)
{
using var context = _dbProvider.CreateDbContext();
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index ac6df54949..82de3546f0 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -832,6 +832,13 @@ namespace MediaBrowser.Controller.Library
/// Aggregated filter values.
QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query);
+ ///
+ /// Gets a list of all distinct tags of the matching items.
+ ///
+ /// The query filter.
+ /// List of tags.
+ IReadOnlyList GetTagNames(InternalItemsQuery query);
+
///
/// Gets a list of all language codes of the provided stream type.
///
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index d44fe57bed..3cf06b897c 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -128,6 +128,13 @@ public interface IItemRepository
/// List of language codes.
public IReadOnlyList GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType);
+ ///
+ /// Gets all distinct tags of the matching base items.
+ ///
+ /// The query filter.
+ /// The list of tags.
+ IReadOnlyList GetTagNames(InternalItemsQuery filter);
+
///
/// Gets all artist names.
///
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
@@ -103,6 +103,50 @@ public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture
Assert.Equal(["Genre Leak"], result.Genres);
}
+ [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()
{