diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 9674ecd092..f8c715dc86 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -530,7 +530,7 @@ public class ItemsController : BaseJellyfinApiController return new QueryResult( startIndex, result.TotalRecordCount, - _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user, skipVisibilityCheck: true)); } /// diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 55ef5972d4..778cedaecf 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -1303,7 +1303,7 @@ public sealed class BaseItemRepository .ToArray(); var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue() { - CleanValue = GetCleanValue(f.Value), + CleanValue = f.Value.GetCleanValue(), ItemValueId = Guid.NewGuid(), Type = f.MagicNumber, Value = f.Value @@ -1877,7 +1877,7 @@ public sealed class BaseItemRepository entity.IndexNumber = dto.IndexNumber; entity.IsLocked = dto.IsLocked; entity.Name = dto.Name; - entity.CleanName = GetCleanValue(dto.Name); + entity.CleanName = dto.Name.GetCleanValue(); entity.OfficialRating = dto.OfficialRating; entity.Overview = dto.Overview; entity.ParentIndexNumber = dto.ParentIndexNumber; @@ -2308,33 +2308,6 @@ public sealed class BaseItemRepository } } - /// - /// Normalizes a value for clean comparison by removing diacritics, punctuation, and converting to lowercase. - /// - /// The value to clean. - /// The normalized value. - public static string GetCleanValue(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } - - // Remove diacritics and convert to lowercase - var cleaned = value.RemoveDiacritics().ToLowerInvariant(); - - // Replace all punctuation and special characters with spaces - // This includes: periods, commas, colons, semicolons, hyphens, underscores, - // parentheses, brackets, braces, quotes, apostrophes, exclamation marks, - // question marks, ampersands, slashes, backslashes, em/en dashes, etc. - cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"[^\p{L}\p{N}\s]", " "); - - // Collapse multiple spaces into single space and trim - cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"\s+", " ").Trim(); - - return cleaned; - } - private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List inheritedTags) { var list = new List<(ItemValueType, string)>(); @@ -2664,7 +2637,7 @@ public sealed class BaseItemRepository if (!string.IsNullOrEmpty(filter.SearchTerm)) { - var cleanedSearchTerm = GetCleanValue(filter.SearchTerm); + var cleanedSearchTerm = filter.SearchTerm.GetCleanValue(); var originalSearchTerm = filter.SearchTerm; if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) { @@ -2893,7 +2866,7 @@ public sealed class BaseItemRepository } else { - var cleanName = GetCleanValue(filter.Name); + var cleanName = filter.Name.GetCleanValue(); baseQuery = baseQuery.Where(e => e.CleanName == cleanName); } } @@ -3078,21 +3051,21 @@ public sealed class BaseItemRepository if (filter.Genres.Count > 0) { - var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); + var cleanGenres = filter.Genres.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); baseQuery = baseQuery .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Genre).Any(cleanGenres)); } if (tags.Count > 0) { - var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); + var cleanValues = tags.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); baseQuery = baseQuery .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); } if (excludeTags.Count > 0) { - var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); + var cleanValues = excludeTags.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder(f => f.ItemValue.CleanValue); baseQuery = baseQuery .Where(e => !e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); } @@ -3486,23 +3459,29 @@ public sealed class BaseItemRepository if (filter.ExcludeInheritedTags.Length > 0) { - var excludedTags = filter.ExcludeInheritedTags; + var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); baseQuery = baseQuery.Where(e => !context.ItemValuesMap.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); + && (f.ItemId == e.Id + || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) + || e.Parents!.Any(p => f.ItemId == p.ParentItemId) + || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); } if (filter.IncludeInheritedTags.Length > 0) { - var includeTags = filter.IncludeInheritedTags; + var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; baseQuery = baseQuery.Where(e => context.ItemValuesMap.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value))) + && (f.ItemId == e.Id + || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) + || e.Parents!.Any(p => f.ItemId == p.ParentItemId) + || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value))) // A playlist should be accessible to its owner regardless of allowed tags || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); @@ -3864,23 +3843,29 @@ public sealed class BaseItemRepository // Apply excluded tags filtering (blocked tags) if (filter.ExcludeInheritedTags.Length > 0) { - var excludedTags = filter.ExcludeInheritedTags; + var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); baseQuery = baseQuery.Where(e => !context.ItemValuesMap.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); + && (f.ItemId == e.Id + || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) + || e.Parents!.Any(p => f.ItemId == p.ParentItemId) + || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); } // Apply included tags filtering (allowed tags - item must have at least one) if (filter.IncludeInheritedTags.Length > 0) { - var includeTags = filter.IncludeInheritedTags; + var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); baseQuery = baseQuery.Where(e => context.ItemValuesMap.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue) - && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); + && (f.ItemId == e.Id + || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value) + || e.Parents!.Any(p => f.ItemId == p.ParentItemId) + || (e.TopParentId.HasValue && f.ItemId == e.TopParentId.Value)))); } return baseQuery; diff --git a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs b/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs index eadabf6776..4b5659cd6c 100644 --- a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs +++ b/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs @@ -61,7 +61,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine { try { - var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : BaseItemRepository.GetCleanValue(item.Name); + var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue(); if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal)) { _logger.LogDebug( diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 13af7a6178..f1c1555842 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1671,16 +1671,26 @@ namespace MediaBrowser.Controller.Entities private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) { - var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags); - var blockedTagsPreference = user.GetPreference(PreferenceKind.BlockedTags); - var needsTagCheck = allowedTagsPreference.Length > 0 || blockedTagsPreference.Length > 0; - if (!needsTagCheck) + var blockedTags = user.GetPreference(PreferenceKind.BlockedTags); + var allowedTags = user.GetPreference(PreferenceKind.AllowedTags); + + if (blockedTags.Length == 0 && allowedTags.Length == 0) { return true; } - var allTags = GetInheritedTags(); - if (blockedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + // Normalize tags using the same logic as database queries + var normalizedBlockedTags = blockedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + var normalizedItemTags = GetInheritedTags() + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + // Check blocked tags - item is hidden if it has any blocked tag + if (normalizedBlockedTags.Overlaps(normalizedItemTags)) { return false; } @@ -1691,9 +1701,18 @@ namespace MediaBrowser.Controller.Entities return true; } - if (!skipAllowedTagsCheck && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + // Check allowed tags - item must have at least one allowed tag + if (!skipAllowedTagsCheck && allowedTags.Length > 0) { - return false; + var normalizedAllowedTags = allowedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + if (!normalizedAllowedTags.Overlaps(normalizedItemTags)) + { + return false; + } } return true; diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index 60df47113a..01225cf283 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -148,5 +148,30 @@ namespace Jellyfin.Extensions { return string.IsNullOrEmpty(text) ? text : text.AsSpan().LeftPart('\0').ToString(); } + + /// + /// Normalizes a string for comparison by removing diacritics, converting to lowercase, + /// replacing punctuation/special characters with spaces, and collapsing whitespace. + /// + /// The string to normalize. + /// The normalized string, or the original if null/whitespace. + public static string GetCleanValue(this string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + // Remove diacritics and convert to lowercase + var cleaned = value.RemoveDiacritics().ToLowerInvariant(); + + // Replace all punctuation and special characters with spaces + cleaned = Regex.Replace(cleaned, @"[^\p{L}\p{N}\s]", " "); + + // Collapse multiple spaces into single space and trim + cleaned = Regex.Replace(cleaned, @"\s+", " ").Trim(); + + return cleaned; + } } }