diff options
Diffstat (limited to 'Jellyfin.Server.Implementations')
7 files changed, 216 insertions, 100 deletions
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index 70483c36c..30094a88c 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -102,7 +102,7 @@ public class BackupService : IBackupService } BackupManifest? manifest; - var manifestStream = zipArchiveEntry.Open(); + var manifestStream = await zipArchiveEntry.OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false); @@ -160,7 +160,7 @@ public class BackupService : IBackupService } HistoryRow[] historyEntries; - var historyArchive = historyEntry.Open(); + var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false); await using (historyArchive.ConfigureAwait(false)) { historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ?? @@ -204,7 +204,7 @@ public class BackupService : IBackupService continue; } - var zipEntryStream = zipEntry.Open(); + var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false); await using (zipEntryStream.ConfigureAwait(false)) { _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name); @@ -329,7 +329,7 @@ public class BackupService : IBackupService _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); var entities = 0; - var zipEntryStream = zipEntry.Open(); + var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false); await using (zipEntryStream.ConfigureAwait(false)) { var jsonSerializer = new Utf8JsonWriter(zipEntryStream); @@ -366,7 +366,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) { - zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); + await zipArchive.CreateEntryFromFileAsync(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))).ConfigureAwait(false); } void CopyDirectory(string source, string target, string filter = "*") @@ -380,6 +380,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) { + // TODO: @bond make async zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); } } @@ -405,7 +406,7 @@ public class BackupService : IBackupService CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); } - var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); + var manifestStream = await zipArchive.CreateEntry(ManifestEntryName).OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); @@ -505,7 +506,7 @@ public class BackupService : IBackupService return null; } - var manifestStream = manifestEntry.Open(); + var manifestStream = await manifestEntry.OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 9851d53c4..cd28c6e43 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -277,7 +277,7 @@ public sealed class BaseItemRepository dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter); - result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); + result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; result.StartIndex = filter.StartIndex ?? 0; return result; } @@ -295,9 +295,28 @@ public sealed class BaseItemRepository dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); + + var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random); + if (hasRandomSort) + { + var orderedIds = dbQuery.Select(e => e.Id).ToList(); + if (orderedIds.Count == 0) + { + return Array.Empty<BaseItemDto>(); + } + + var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter) + .AsEnumerable() + .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); + + return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!; + } + dbQuery = ApplyNavigations(dbQuery, filter); - return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); + return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; } /// <inheritdoc/> @@ -341,7 +360,7 @@ public sealed class BaseItemRepository mainquery = ApplyNavigations(mainquery, filter); - return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); + return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; } /// <inheritdoc /> @@ -410,10 +429,25 @@ public sealed class BaseItemRepository private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { - dbQuery = dbQuery.Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields) - .Include(e => e.UserData); + if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)) + { + dbQuery = dbQuery.Include(e => e.TrailerTypes); + } + + if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds)) + { + dbQuery = dbQuery.Include(e => e.Provider); + } + + if (filter.DtoOptions.ContainsField(ItemFields.Settings)) + { + dbQuery = dbQuery.Include(e => e.LockedFields); + } + + if (filter.DtoOptions.EnableUserData) + { + dbQuery = dbQuery.Include(e => e.UserData); + } if (filter.DtoOptions.EnableImages) { @@ -609,7 +643,6 @@ public sealed class BaseItemRepository var ids = tuples.Select(f => f.Item.Id).ToArray(); var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); - var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray(); foreach (var item in tuples) { @@ -643,19 +676,6 @@ public sealed class BaseItemRepository context.SaveChanges(); - foreach (var item in newItems) - { - // reattach old userData entries - var userKeys = item.UserDataKey.ToArray(); - var retentionDate = (DateTime?)null; - context.UserData - .Where(e => e.ItemId == PlaceholderId) - .Where(e => userKeys.Contains(e.CustomDataKey)) - .ExecuteUpdate(e => e - .SetProperty(f => f.ItemId, item.Item.Id) - .SetProperty(f => f.RetentionDate, retentionDate)); - } - var itemValueMaps = tuples .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) .ToArray(); @@ -663,14 +683,15 @@ public sealed class BaseItemRepository .SelectMany(f => f.Values) .Distinct() .ToArray(); + + var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray(); + var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray(); + var allListedItemValuesSet = allListedItemValues.ToHashSet(); + var existingValues = context.ItemValues - .Select(e => new - { - item = e, - Key = e.Type + "+" + e.Value - }) - .Where(f => allListedItemValues.Select(e => $"{(int)e.MagicNumber}+{e.Value}").Contains(f.Key)) - .Select(e => e.item) + .Where(e => types.Contains(e.Type) && values.Contains(e.Value)) + .AsEnumerable() + .Where(e => allListedItemValuesSet.Contains((e.Type, e.Value))) .ToArray(); var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue() { @@ -752,6 +773,43 @@ public sealed class BaseItemRepository } /// <inheritdoc /> + public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + cancellationToken.ThrowIfCancellationRequested(); + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + await using (dbContext.ConfigureAwait(false)) + { + var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using (transaction.ConfigureAwait(false)) + { + var userKeys = item.GetUserDataKeys().ToArray(); + var retentionDate = (DateTime?)null; + + await dbContext.UserData + .Where(e => e.ItemId == PlaceholderId) + .Where(e => userKeys.Contains(e.CustomDataKey)) + .ExecuteUpdateAsync( + e => e + .SetProperty(f => f.ItemId, item.Id) + .SetProperty(f => f.RetentionDate, retentionDate), + cancellationToken).ConfigureAwait(false); + + // Rehydrate the cached userdata + item.UserData = await dbContext.UserData + .AsNoTracking() + .Where(e => e.ItemId == item.Id) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + /// <inheritdoc /> public BaseItemDto? RetrieveItem(Guid id) { if (id.IsEmpty()) @@ -858,7 +916,7 @@ public sealed class BaseItemRepository } dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray(); - dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? []; + dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? []; dto.Studios = entity.Studios?.Split('|') ?? []; dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|'); @@ -993,7 +1051,7 @@ public sealed class BaseItemRepository entity.TotalBitrate = dto.TotalBitrate; entity.ExternalId = dto.ExternalId; entity.Size = dto.Size; - entity.Genres = string.Join('|', dto.Genres); + entity.Genres = string.Join('|', dto.Genres.Distinct(StringComparer.OrdinalIgnoreCase)); entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated; entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified; entity.ChannelId = dto.ChannelId; @@ -1020,9 +1078,9 @@ public sealed class BaseItemRepository } entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null; - entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null; - entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null; - entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null; + entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase)) : null; + entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios.Distinct(StringComparer.OrdinalIgnoreCase)) : null; + entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags.Distinct(StringComparer.OrdinalIgnoreCase)) : null; entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields .Select(e => new BaseItemMetadataField() { @@ -1065,12 +1123,12 @@ public sealed class BaseItemRepository if (dto is IHasArtist hasArtists) { - entity.Artists = hasArtists.Artists is not null ? string.Join('|', hasArtists.Artists) : null; + entity.Artists = hasArtists.Artists is not null ? string.Join('|', hasArtists.Artists.Distinct(StringComparer.OrdinalIgnoreCase)) : null; } if (dto is IHasAlbumArtist hasAlbumArtists) { - entity.AlbumArtists = hasAlbumArtists.AlbumArtists is not null ? string.Join('|', hasAlbumArtists.AlbumArtists) : null; + entity.AlbumArtists = hasAlbumArtists.AlbumArtists is not null ? string.Join('|', hasAlbumArtists.AlbumArtists.Distinct(StringComparer.OrdinalIgnoreCase)) : null; } if (dto is LiveTvProgram program) @@ -1144,7 +1202,7 @@ public sealed class BaseItemRepository return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null; } - private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) + private BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) { ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity)); if (_serverConfigurationManager?.Configuration is null) @@ -1167,11 +1225,19 @@ public sealed class BaseItemRepository /// <param name="logger">Logger.</param> /// <param name="appHost">The application server Host.</param> /// <param name="skipDeserialization">If only mapping should be processed.</param> - /// <returns>A mapped BaseItem.</returns> - /// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception> - public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) + /// <returns>A mapped BaseItem, or null if the item type is unknown.</returns> + public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) { - var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type."); + var type = GetType(baseItemEntity.Type); + if (type is null) + { + logger.LogWarning( + "Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.", + baseItemEntity.Id, + baseItemEntity.Type); + return null; + } + BaseItemDto? dto = null; if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization) { @@ -1338,10 +1404,9 @@ public sealed class BaseItemRepository .. resultQuery .AsEnumerable() .Where(e => e is not null) - .Select(e => - { - return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount); - }) + .Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount)) + .Where(e => e.Item is not null) + .Select(e => (e.Item!, e.itemCount)) ]; } else @@ -1352,10 +1417,9 @@ public sealed class BaseItemRepository .. query .AsEnumerable() .Where(e => e is not null) - .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => - { - return (DeserializeBaseItem(e, filter.SkipDeserialization), null); - }) + .Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null)) + .Where(e => e.Item is not null) + .Select(e => (e.Item!, e.ItemCounts)) ]; } @@ -1571,43 +1635,50 @@ public sealed class BaseItemRepository private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context) { - var orderBy = filter.OrderBy; + var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray(); var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); if (hasSearch) { - orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; + orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; } - else if (orderBy.Count == 0) + else if (orderBy.Length == 0) { return query.OrderBy(e => e.SortName); } IOrderedQueryable<BaseItemEntity>? orderedQuery = null; + // When searching, prioritize by match quality: exact match > prefix match > contains + if (hasSearch) + { + orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!)); + } + var firstOrdering = orderBy.FirstOrDefault(); if (firstOrdering != default) { var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); - if (firstOrdering.SortOrder == SortOrder.Ascending) + if (orderedQuery is null) { - orderedQuery = query.OrderBy(expression); + // No search relevance ordering, start fresh + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending + ? query.OrderBy(expression) + : query.OrderByDescending(expression); } else { - orderedQuery = query.OrderByDescending(expression); + // Search relevance ordering already applied, chain with ThenBy + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(expression) + : orderedQuery.ThenByDescending(expression); } if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) { - if (firstOrdering.SortOrder is SortOrder.Ascending) - { - orderedQuery = orderedQuery.ThenBy(e => e.Name); - } - else - { - orderedQuery = orderedQuery.ThenByDescending(e => e.Name); - } + orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending + ? orderedQuery.ThenBy(e => e.Name) + : orderedQuery.ThenByDescending(e => e.Name); } } @@ -1972,8 +2043,15 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Name)) { - var cleanName = GetCleanValue(filter.Name); - baseQuery = baseQuery.Where(e => e.CleanName == cleanName); + if (filter.UseRawName == true) + { + baseQuery = baseQuery.Where(e => e.Name == filter.Name); + } + else + { + var cleanName = GetCleanValue(filter.Name); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); + } } // These are the same, for now @@ -2467,35 +2545,24 @@ public sealed class BaseItemRepository if (filter.ExcludeInheritedTags.Length > 0) { + var excludedTags = filter.ExcludeInheritedTags; baseQuery = baseQuery.Where(e => - !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)) - && (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue || - !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)))); + !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) + && (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)))); } if (filter.IncludeInheritedTags.Length > 0) { - // For seasons and episodes, we also need to check the parent series' tags. - if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season)) - { - baseQuery = baseQuery.Where(e => - e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) - || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); - } + var includeTags = filter.IncludeInheritedTags; + var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) - // A playlist should be accessible to its owner regardless of allowed tags. - else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) - { - baseQuery = baseQuery.Where(e => - e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) - || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")); - // d ^^ this is stupid it hate this. - } - else - { - baseQuery = baseQuery.Where(e => - e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))); - } + // For seasons and episodes, we also need to check the parent series' tags. + || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))) + + // A playlist should be accessible to its owner regardless of allowed tags + || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); } if (filter.SeriesStatuses.Length > 0) @@ -2649,6 +2716,21 @@ public sealed class BaseItemRepository .Where(e => artistNames.Contains(e.Name)) .ToArray(); - return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray()); + var lookup = artists + .GroupBy(e => e.Name!) + .ToDictionary( + g => g.Key, + g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); + + var result = new Dictionary<string, MusicArtist[]>(artistNames.Count); + foreach (var name in artistNames) + { + if (lookup.TryGetValue(name, out var artistArray)) + { + result[name] = artistArray; + } + } + + return result; } } diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 7eb13b740..64874ccad 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -158,6 +158,12 @@ public class MediaStreamRepository : IMediaStreamRepository dto.LocalizedDefault = _localization.GetLocalizedString("Default"); dto.LocalizedExternal = _localization.GetLocalizedString("External"); + if (!string.IsNullOrEmpty(dto.Language)) + { + var culture = _localization.FindLanguageInfo(dto.Language); + dto.LocalizedLanguage = culture?.DisplayName; + } + if (dto.Type is MediaStreamType.Subtitle) { dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index 192ee7499..1ae7cc6c4 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -6,6 +6,7 @@ using System.Linq.Expressions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using Microsoft.EntityFrameworkCore; @@ -68,4 +69,30 @@ public static class OrderMapper _ => e => e.SortName }; } + + /// <summary> + /// Creates an expression to order search results by match quality. + /// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3). + /// </summary> + /// <param name="searchTerm">The search term to match against.</param> + /// <returns>An expression that returns an integer representing match quality (lower is better).</returns> + public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm) + { + var cleanSearchTerm = GetCleanValue(searchTerm); + var searchPrefix = cleanSearchTerm + " "; + return e => + e.CleanName == cleanSearchTerm ? 0 : + e.CleanName!.StartsWith(searchPrefix) ? 1 : + e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3; + } + + private static string GetCleanValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + return value.RemoveDiacritics().ToLowerInvariant(); + } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 355ed6479..e2569241d 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -74,9 +74,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I /// <inheritdoc /> public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people) { - foreach (var item in people.Where(e => e.Role is null)) + foreach (var person in people) { - item.Role = string.Empty; + person.Name = person.Name.Trim(); + person.Role = person.Role?.Trim() ?? string.Empty; } // multiple metadata providers can provide the _same_ person diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 6693ab8db..4f0c37722 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -27,7 +27,6 @@ <ItemGroup> <PackageReference Include="AsyncKeyedLock" /> - <PackageReference Include="System.Linq.Async" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> </ItemGroup> diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 63d962ad9..501cb4fbe 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users ThrowIfInvalidUsername(newName); - if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase)) + if (user.Username.Equals(newName, StringComparison.Ordinal)) { throw new ArgumentException("The new and old names must be different."); } |
