diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-10 23:03:52 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-10 23:03:52 +0200 |
| commit | 7b6ae06f3bc3f630828a77c0f2b0c2e1d6bd85cd (patch) | |
| tree | 2fe06b0dcd958836550c5f2d62321957c2828d81 | |
| parent | 8c4dfc0b710c9314061911eea0daacfd855326e4 (diff) | |
| parent | 35e86416af32adaa220a241f756544cd316bd866 (diff) | |
Merge remote-tracking branch 'upstream/master' into safeguard-invalid-provider-ids
22 files changed, 508 insertions, 188 deletions
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index da0c52df5b..6fa057702c 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -253,6 +253,18 @@ namespace Emby.Server.Implementations.Dto } } + // Batch-detect which videos own alternate versions to avoid the per-item alternate-version + // queries in MediaSourceCount. Videos absent from this set have a single media source. + IReadOnlySet<Guid>? alternateVersionItemIds = null; + if (options.ContainsField(ItemFields.MediaSourceCount)) + { + var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList(); + if (versionItemIds.Count > 0) + { + alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds); + } + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; @@ -267,7 +279,8 @@ namespace Emby.Server.Implementations.Dto playedCountBatch, artistsBatch, resumeDataBatch?.GetValueOrDefault(item.Id), - peopleBatch); + peopleBatch, + alternateVersionItemIds); if (item is LiveTvChannel tvChannel) { @@ -330,7 +343,8 @@ namespace Emby.Server.Implementations.Dto Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, VersionResumeData? resumeData = null, - IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null) + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null, + IReadOnlySet<Guid>? alternateVersionItemIds = null) { var dto = new BaseItemDto { @@ -399,7 +413,7 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options, artistsBatch, user); + AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds); if (options.ContainsField(ItemFields.CanDelete)) { @@ -984,7 +998,8 @@ namespace Emby.Server.Implementations.Dto /// <param name="options">The options.</param> /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> /// <param name="user">The user, for per-user values such as the accessible media source count.</param> - private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null) + /// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param> + private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -1298,15 +1313,27 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaSourceCount)) { - // Match the per-user filtering of the media sources: versions the user cannot - // access are not selectable, so they must not count towards the badge either. - var mediaSourceCount = user is null - || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions) - ? video.MediaSourceCount - : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); - if (mediaSourceCount != 1) + // A video with no primary version and no alternate versions always has a single + // media source. Only compute the count for videos that might have more: a primary + // version, or membership in the batch's set of items that own alternate versions. + // Without the batch we can't rule it out, so fall back to computing (the single-item + // path). Everything else is the common case and keeps the default count of one. + var mayHaveAlternateVersions = alternateVersionItemIds is null + || video.PrimaryVersionId.HasValue + || alternateVersionItemIds.Contains(video.Id); + + if (mayHaveAlternateVersions) { - dto.MediaSourceCount = mediaSourceCount; + // Match the per-user filtering of the media sources: versions the user cannot + // access are not selectable, so they must not count towards the badge either. + var mediaSourceCount = user is null + || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions) + ? video.MediaSourceCount + : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); + if (mediaSourceCount != 1) + { + dto.MediaSourceCount = mediaSourceCount; + } } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 19371f68d7..0c7c411d0c 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2235,6 +2235,12 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds); + } + + /// <inheritdoc /> public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType) { _linkedChildrenService.UpsertLinkedChild(parentId, childId, childType); diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index d923cff07e..4e482c174a 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -183,6 +183,7 @@ public class SimilarItemsManager : ISimilarItemsManager // Collect references in batches and resolve against local library. // Stop fetching once we have enough resolved local items. const int BatchSize = 20; + const int MaxRemoteReferenceFetchLimit = 500; var remaining = requestedLimit - allResults.Count; var collectedReferences = new List<SimilarItemReference>(); var pendingBatch = new List<SimilarItemReference>(); @@ -199,7 +200,7 @@ public class SimilarItemsManager : ISimilarItemsManager remaining -= resolvedItems.Count; pendingBatch.Clear(); - if (remaining <= 0) + if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit) { break; } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index 9d346a1341..137aa15ad5 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -51,7 +51,7 @@ "Shows": "Laidos", "StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.", "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", - "TvShows": "TV laidos", + "TvShows": "TV Laidos", "UserCreatedWithName": "Buvo sukurtas {0} naudotojas", "UserDeletedWithName": "Naudotojas {0} ištrintas", "UserDownloadingItemWithValues": "{0} siunčiasi {1}", diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 1db500adf3..031c6e17c4 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Tarefa de limpeza de dados do usuário", "CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.", "LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Nos Bastidores", + "NameExtraClip": "Clipe", + "NameExtraDeletedScene": "cena Extra", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Trecho", + "NameExtraScene": "Cena", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Música Tema", + "NameExtraThemeVideo": "Vídeo de Abertura", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraFeaturette": "Nos Bastidores" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index ce338acf34..9a6e213d9a 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -95,18 +95,30 @@ "HearingImpaired": "Problemas auditivos", "TaskKeyframeExtractor": "Extrator de quadro-chave", "TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.", - "TaskRefreshTrickplayImages": "Gerar imagens de trickplay", - "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.", + "TaskRefreshTrickplayImages": "Gerar imagens de Trickplay", + "TaskRefreshTrickplayImagesDescription": "Cria miniaturas de pré-visualização (Trickplay) para vídeos nas bibliotecas ativadas.", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", "TaskAudioNormalization": "Normalização de áudio", "TaskDownloadMissingLyrics": "Transferir letra em falta", "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", - "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", + "TaskMoveTrickplayImagesDescription": "Move os ficheiros Trickplay existentes de acordo com as definições da mediateca.", "TaskExtractMediaSegments": "Analisar segmentos de multimédia", "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", + "TaskMoveTrickplayImages": "Migrar a localização das imagens de Trickplay", "CleanupUserDataTask": "Task de limpeza de dados do usuário", "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.", "Original": "Original", - "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" + "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}", + "NameExtraBehindTheScenes": "Bastidores", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Cena Eliminada", + "NameExtraFeaturette": "Média-metragem", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Amostra", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Tema Principal", + "NameExtraThemeVideo": "Vídeo de Abertura", + "NameExtraScene": "Cena", + "NameExtraUnknown": "Extra", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 40d5e3985d..6382fc083f 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -108,5 +108,16 @@ "CleanupUserDataTask": "Задача очистки пользовательских данных", "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.", "Original": "Оригинальный", - "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}" + "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}", + "NameExtraBehindTheScenes": "За кулисами", + "NameExtraClip": "Отрывок", + "NameExtraDeletedScene": "Удалённая сцена", + "NameExtraFeaturette": "Короткометражка", + "NameExtraInterview": "Интервью", + "NameExtraSample": "Образец", + "NameExtraScene": "Сцена", + "NameExtraThemeSong": "Заглавная песня", + "NameExtraThemeVideo": "Заглавное видео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Дополнительный материал" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 6275da648f..77619b24d0 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -32,7 +32,7 @@ "TasksLibraryCategory": "Thư Viện", "TasksMaintenanceCategory": "Bảo Trì", "VersionNumber": "Phiên Bản {0}", - "UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}", + "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}", "UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}", "UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}", "UserOnlineFromDevice": "{0} trực tuyến từ {1}", @@ -79,7 +79,7 @@ "HeaderNextUp": "Tiếp Theo", "HeaderFavoriteShows": "Chương Trình Yêu Thích", "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", - "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}", + "FailedLoginAttemptWithUserName": "Cố gắng đăng nhập thất bại từ {0}", "ChapterNameValue": "Phân Cảnh {0}", "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", @@ -95,15 +95,15 @@ "TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.", "External": "Bên ngoài", "HearingImpaired": "Khiếm Thính", - "TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay", - "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.", + "TaskRefreshTrickplayImages": "Tạo Ảnh Tua Nhanh (Trickplay)", + "TaskRefreshTrickplayImagesDescription": "Tạo ảnh tua nhanh (trịckplay) xem thử cho các video trong các thư viện được kích hoạt.", "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh", "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.", "TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát", "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu", "TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.", - "TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay", - "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.", + "TaskMoveTrickplayImages": "Di Chuyển Vị Trí Ảnh Tua Nhanh (Trickplay)", + "TaskMoveTrickplayImagesDescription": "Di chuyển các tệp ảnh tua nhanh (trickplay) hiện có theo cài đặt thư viện.", "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện", "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng", "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.", @@ -117,5 +117,9 @@ "NameExtraScene": "Cảnh", "NameExtraShort": "Ngắn", "NameExtraThemeSong": "Bài Hát Chủ Đề", - "NameExtraThemeVideo": "Video Chủ Đề" + "NameExtraThemeVideo": "Video Chủ Đề", + "NameExtraFeaturette": "Nội dung phụ", + "NameExtraClip": "Clip ngắn", + "NameExtraTrailer": "Đoạn giới thiệu", + "NameExtraUnknown": "Nội dung bổ sung" } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 8c0a39fe4c..4be9b04baa 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -356,7 +356,7 @@ public sealed partial class BaseItemRepository } else { - baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now); + baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now); } } @@ -370,14 +370,16 @@ public sealed partial class BaseItemRepository p => p.Name, (b, p) => p.Id); + var personTypes = filter.PersonTypes; baseQuery = baseQuery .Where(e => context.PeopleBaseItemMap - .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId))); + .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType)))); } if (!string.IsNullOrWhiteSpace(filter.Person)) { - baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person)); + var personTypes = filter.PersonTypes; + baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType)))); } if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId)) @@ -555,7 +557,7 @@ public sealed partial class BaseItemRepository if (filter.ArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); } if (filter.AlbumArtistIds.Length > 0) @@ -586,12 +588,12 @@ public sealed partial class BaseItemRepository if (filter.ExcludeArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); } if (filter.GenreIds.Count > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray()); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds); } if (filter.Genres.Count > 0) @@ -617,7 +619,7 @@ public sealed partial class BaseItemRepository if (filter.StudioIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray()); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds); } if (filter.OfficialRatings.Length > 0) @@ -963,17 +965,6 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds); } - if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) - { - var includeAny = filter.HasAnyProviderIds - .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) - .ToArray(); - if (includeAny.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f))); - } - } - if (filter.HasImdbId.HasValue) { baseQuery = filter.HasImdbId.Value diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 5f1d9bf87a..d46f7b3c4c 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -60,6 +60,25 @@ public class LinkedChildrenService : ILinkedChildrenService } /// <inheritdoc/> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + if (itemIds.Count == 0) + { + return new HashSet<Guid>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + + return dbContext.LinkedChildren + .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion + || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion) + .WhereOneOrMany(itemIds as IList<Guid> ?? itemIds.ToList(), lc => lc.ParentId) + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + } + + /// <inheritdoc/> public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames) { using var dbContext = _dbProvider.CreateDbContext(); diff --git a/MediaBrowser.Controller/Drawing/ImageHelper.cs b/MediaBrowser.Controller/Drawing/ImageHelper.cs index 9ef92bc981..6f26b7d912 100644 --- a/MediaBrowser.Controller/Drawing/ImageHelper.cs +++ b/MediaBrowser.Controller/Drawing/ImageHelper.cs @@ -11,7 +11,9 @@ namespace MediaBrowser.Controller.Drawing // Determine the output size based on incoming parameters var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0); newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight); - return newSize; + + // Never encode larger than the source. + return DrawingUtils.ScaleDownToFit(newSize, originalImageSize); } } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 5eae6e103f..ca686fbd9d 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -256,6 +256,14 @@ namespace MediaBrowser.Controller.Library IEnumerable<Video> GetLinkedAlternateVersions(Video video); /// <summary> + /// Gets, in a single query, the subset of the supplied items that own at least one alternate + /// version (local or linked). Items absent from the result have no alternate versions. + /// </summary> + /// <param name="itemIds">The item IDs to check.</param> + /// <returns>The set of item IDs that have alternate versions.</returns> + IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Creates or updates a LinkedChild entry linking a parent to a child item. /// </summary> /// <param name="parentId">The parent item ID.</param> diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs index a4614fc125..79c29410e4 100644 --- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -20,6 +20,15 @@ public interface ILinkedChildrenService IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null); /// <summary> + /// Gets, in a single query, the subset of the supplied items that own at least one alternate + /// version (local or linked). Items absent from the result have no alternate versions, so their + /// media source count is one. + /// </summary> + /// <param name="itemIds">The item IDs to check.</param> + /// <returns>The set of item IDs that have alternate versions.</returns> + IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Gets all artist matches from the database. /// </summary> /// <param name="artistNames">The names of the artists.</param> diff --git a/MediaBrowser.Model/Drawing/DrawingUtils.cs b/MediaBrowser.Model/Drawing/DrawingUtils.cs index 2040d26bbb..1fdd6a4a49 100644 --- a/MediaBrowser.Model/Drawing/DrawingUtils.cs +++ b/MediaBrowser.Model/Drawing/DrawingUtils.cs @@ -104,6 +104,35 @@ namespace MediaBrowser.Model.Drawing } /// <summary> + /// Scales a size down uniformly until it fits inside a bounding box. + /// Returns the original size if it already fits, so this never upscales. + /// </summary> + /// <param name="size">The size object.</param> + /// <param name="boundingBox">The box the result has to fit inside.</param> + /// <returns>A new size object, or <paramref name="size"/> if it already fits.</returns> + public static ImageDimensions ScaleDownToFit(ImageDimensions size, ImageDimensions boundingBox) + { + if (size.Width <= 0 || size.Height <= 0 || boundingBox.Width <= 0 || boundingBox.Height <= 0) + { + return size; + } + + double widthRatio = size.Width / (double)boundingBox.Width; + double heightRatio = size.Height / (double)boundingBox.Height; + double scaleRatio = Math.Max(widthRatio, heightRatio); + + if (scaleRatio <= 1) + { + return size; + } + + var newWidth = Math.Clamp(Convert.ToInt32(Math.Round(size.Width / scaleRatio)), 1, boundingBox.Width); + var newHeight = Math.Clamp(Convert.ToInt32(Math.Round(size.Height / scaleRatio)), 1, boundingBox.Height); + + return new ImageDimensions(newWidth, newHeight); + } + + /// <summary> /// Gets the new width. /// </summary> /// <param name="currentHeight">Height of the current.</param> diff --git a/bump_version b/bump_version index 0516a1806d..7525721c0c 100755 --- a/bump_version +++ b/bump_version @@ -27,6 +27,7 @@ jellyfin_subprojects=( MediaBrowser.Model/MediaBrowser.Model.csproj Emby.Naming/Emby.Naming.csproj src/Jellyfin.Extensions/Jellyfin.Extensions.csproj + src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj ) issue_template_file="./.github/ISSUE_TEMPLATE/issue report.yml" diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj index 0b29a71cbd..887ba114fc 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj @@ -13,7 +13,6 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Database.Implementations</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index fec37ce723..0dfce732ce 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -14,11 +14,17 @@ namespace Jellyfin.Database.Implementations; /// <summary> /// Contains a number of query related extensions. /// </summary> +/// <remarks> +/// Every helper here binds its values through <see cref="EF.Parameter{T}(T)"/>. Values embedded as bare +/// constants are inlined into the SQL as literals, which gives each distinct value its own entry in EF's +/// compiled query cache and its own statement for the database to plan. +/// </remarks> public static class JellyfinQueryHelperExtensions { private static readonly MethodInfo _containsMethodGenericCache = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static).First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2); private static readonly MethodInfo _efParameterInstruction = typeof(EF).GetMethod(nameof(EF.Parameter), BindingFlags.Public | BindingFlags.Static)!; private static readonly ConcurrentDictionary<Type, MethodInfo> _containsQueryCache = new(); + private static readonly ConcurrentDictionary<Type, MethodInfo> _efParameterCache = new(); /// <summary> /// Builds an optimised query checking one property against a list of values while maintaining an optimal query. @@ -26,15 +32,69 @@ public static class JellyfinQueryHelperExtensions /// <typeparam name="TEntity">The entity.</typeparam> /// <typeparam name="TProperty">The property type to compare.</typeparam> /// <param name="query">The source query.</param> - /// <param name="oneOf">The list of items to check.</param> + /// <param name="oneOf">The list of items to check. An empty list matches nothing.</param> /// <param name="property">Property expression.</param> /// <returns>A Query.</returns> - public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) + public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) { return query.Where(OneOrManyExpressionBuilder(oneOf, property)); } /// <summary> + /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query. + /// </summary> + /// <typeparam name="TEntity">The entity.</typeparam> + /// <typeparam name="TProperty">The property type to compare.</typeparam> + /// <param name="oneOf">The list of items to check. An empty list matches nothing.</param> + /// <param name="property">Property expression.</param> + /// <returns>A Query.</returns> + public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) + { + ArgumentNullException.ThrowIfNull(oneOf); + ArgumentNullException.ThrowIfNull(property); + + var parameter = Expression.Parameter(typeof(TEntity), "item"); + property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter); + + if (oneOf.Count == 0) + { + // Fail closed, and without asking the database to unpack an empty collection to prove it. + return Expression.Lambda<Func<TEntity, bool>>(Expression.Constant(false), parameter); + } + + if (oneOf.Count == 1) + { + var value = Expression.Call( + null, + EfParameterFor(typeof(TProperty)), + Expression.Constant(oneOf[0], typeof(TProperty))); + + return Expression.Lambda<Func<TEntity, bool>>( + typeof(TProperty).IsValueType + ? Expression.Equal(property.Body, value) + : Expression.ReferenceEqual(property.Body, value), + parameter); + } + + var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); + + // Binding the whole collection as one parameter keeps the statement identical for any element + // count, instead of emitting one placeholder per element. + return Expression.Lambda<Func<TEntity, bool>>( + Expression.Call( + null, + containsMethodInfo, + Expression.Call(null, EfParameterFor(oneOf.GetType()), Expression.Constant(oneOf)), + property.Body), + parameter); + } + + private static MethodInfo EfParameterFor(Type type) + { + return _efParameterCache.GetOrAdd(type, static (key) => _efParameterInstruction.MakeGenericMethod(key)); + } + + /// <summary> /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup. /// </summary> /// <param name="baseQuery">The source query.</param> @@ -47,207 +107,160 @@ public static class JellyfinQueryHelperExtensions this IQueryable<BaseItemEntity> baseQuery, JellyfinDbContext context, ItemValueType itemValueType, - IList<Guid> referenceIds, + IReadOnlyList<Guid> referenceIds, bool invert = false) { - return baseQuery.Where(ReferencedItemFilterExpressionBuilder(context, itemValueType, referenceIds, invert)); + return baseQuery.WhereReferencedItem(context, [itemValueType], referenceIds, invert); } /// <summary> - /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup. + /// Builds a query that checks referenced ItemValues of any of the given types for a cross BaseItem lookup. /// </summary> /// <param name="baseQuery">The source query.</param> /// <param name="context">The database context.</param> - /// <param name="itemValueTypes">The type of item value to reference.</param> + /// <param name="itemValueTypes">The types of item value to reference.</param> /// <param name="referenceIds">The list of BaseItem ids to check matches.</param> /// <param name="invert">If set an exclusion check is performed instead.</param> /// <returns>A Query.</returns> - public static IQueryable<BaseItemEntity> WhereReferencedItemMultipleTypes( + /// <remarks> + /// Matching is on CleanName alone. Genre/artist/album etc items do not set an ItemValue of their own + /// type, so the referenced item's Type is never consulted and ids whose names clean to the same value + /// are interchangeable across types. + /// </remarks> + public static IQueryable<BaseItemEntity> WhereReferencedItem( this IQueryable<BaseItemEntity> baseQuery, JellyfinDbContext context, - IList<ItemValueType> itemValueTypes, - IList<Guid> referenceIds, + IReadOnlyList<ItemValueType> itemValueTypes, + IReadOnlyList<Guid> referenceIds, bool invert = false) { - var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id); - var typeFilter = OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type); + ArgumentNullException.ThrowIfNull(context); - // Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)). + // Flat sub-selects rather than a correlated .Any(...Any(...)). var referencedCleanValues = context.BaseItems - .Where(itemFilter) + .Where(OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, e => e.Id)) .Select(e => e.CleanName); var matchingItemIds = context.ItemValuesMap - .Where(typeFilter) + .Where(OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type)) .Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue)) .Select(m => m.ItemId); - if (invert) - { - return baseQuery.Where(e => !matchingItemIds.Contains(e.Id)); - } - - return baseQuery.Where(e => matchingItemIds.Contains(e.Id)); + return invert + ? baseQuery.Where(e => !matchingItemIds.Contains(e.Id)) + : baseQuery.Where(e => matchingItemIds.Contains(e.Id)); } /// <summary> - /// Builds a query expression that checks referenced ItemValues for a cross BaseItem lookup. - /// </summary> - /// <param name="context">The database context.</param> - /// <param name="itemValueType">The type of item value to reference.</param> - /// <param name="referenceIds">The list of BaseItem ids to check matches.</param> - /// <param name="invert">If set an exclusion check is performed instead.</param> - /// <returns>A Query.</returns> - public static Expression<Func<BaseItemEntity, bool>> ReferencedItemFilterExpressionBuilder( - this JellyfinDbContext context, - ItemValueType itemValueType, - IList<Guid> referenceIds, - bool invert = false) - { - // Well genre/artist/album etc items do not actually set the ItemValue of thier specitic types so we cannot match it that way. - /* - "(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@GenreIds and Type=2)))" - */ - - var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id); - - // Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)). - var referencedCleanValues = context.BaseItems - .Where(itemFilter) - .Select(e => e.CleanName); - - var matchingItemIds = context.ItemValuesMap - .Where(m => m.ItemValue.Type == itemValueType && referencedCleanValues.Contains(m.ItemValue.CleanValue)) - .Select(m => m.ItemId); - - if (invert) - { - return item => !matchingItemIds.Contains(item.Id); - } - - return item => matchingItemIds.Contains(item.Id); - } - - /// <summary> - /// Filters items that match any of the specified (provider name, value) pairs. + /// Filters items that have any of the specified providers, optionally restricted to given values. /// </summary> /// <param name="baseQuery">The source query.</param> - /// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param> + /// <param name="providerIds">Dictionary mapping provider names to values to match. An empty value array matches any value for that provider.</param> /// <returns>A filtered query.</returns> public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds( this IQueryable<BaseItemEntity> baseQuery, IReadOnlyDictionary<string, string[]> providerIds) { - var providerKeys = providerIds - .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) - .ToList(); - - if (providerKeys.Count == 0) - { - return baseQuery; - } - - return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + return baseQuery.WhereProviderMatch(Flatten(providerIds), false); } /// <summary> - /// Filters items that have any of the specified providers. Empty/null values match any value for that provider. + /// Filters items that have any of the specified providers, optionally restricted to a given value. /// </summary> /// <param name="baseQuery">The source query.</param> - /// <param name="providerIds">Dictionary mapping provider names to optional values.</param> + /// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value matches any value for that provider.</param> /// <returns>A filtered query.</returns> public static IQueryable<BaseItemEntity> WhereHasAnyProviderId( this IQueryable<BaseItemEntity> baseQuery, IReadOnlyDictionary<string, string> providerIds) { - var existenceOnly = providerIds - .Where(e => string.IsNullOrEmpty(e.Value)) - .Select(e => e.Key) - .ToList(); - - var specificValues = providerIds - .Where(e => !string.IsNullOrEmpty(e.Value)) - .Select(e => $"{e.Key}:{e.Value}") - .ToList(); - - if (existenceOnly.Count == 0 && specificValues.Count == 0) - { - return baseQuery; - } - - if (existenceOnly.Count == 0) - { - return baseQuery.Where(e => e.Provider!.Any(p => - specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); - } - - if (specificValues.Count == 0) - { - return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId))); - } - - // Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries. - return baseQuery.Where(e => e.Provider!.Any(p => - existenceOnly.Contains(p.ProviderId) || - specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); + return baseQuery.WhereProviderMatch(providerIds, false); } /// <summary> - /// Excludes items that match any of the specified (provider name, value) pairs. + /// Excludes items that have any of the specified providers, optionally restricted to a given value. /// </summary> /// <param name="baseQuery">The source query.</param> - /// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param> + /// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value excludes any value for that provider.</param> /// <returns>A filtered query.</returns> public static IQueryable<BaseItemEntity> WhereExcludeProviderIds( this IQueryable<BaseItemEntity> baseQuery, IReadOnlyDictionary<string, string> providerIds) { - var excludeKeys = providerIds - .Select(e => $"{e.Key}:{e.Value}") - .ToList(); + return baseQuery.WhereProviderMatch(providerIds, true); + } + + private static IEnumerable<KeyValuePair<string, string>> Flatten(IReadOnlyDictionary<string, string[]> providerIds) + { + ArgumentNullException.ThrowIfNull(providerIds); - if (excludeKeys.Count == 0) + foreach (var (provider, values) in providerIds) { - return baseQuery; - } + if (values is null || values.Length == 0) + { + yield return new KeyValuePair<string, string>(provider, string.Empty); + continue; + } - return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + foreach (var value in values) + { + yield return new KeyValuePair<string, string>(provider, value); + } + } } /// <summary> - /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query. + /// Matches items against a set of (provider, value) pairs, where an empty value means any value for + /// that provider. Emits a single EXISTS over the provider collection with the predicates OR'd, rather + /// than one subquery per predicate group. /// </summary> - /// <typeparam name="TEntity">The entity.</typeparam> - /// <typeparam name="TProperty">The property type to compare.</typeparam> - /// <param name="oneOf">The list of items to check.</param> - /// <param name="property">Property expression.</param> - /// <returns>A Query.</returns> - public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) + private static IQueryable<BaseItemEntity> WhereProviderMatch( + this IQueryable<BaseItemEntity> baseQuery, + IEnumerable<KeyValuePair<string, string>> providerIds, + bool invert) { - var parameter = Expression.Parameter(typeof(TEntity), "item"); - property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter); - if (oneOf.Count == 1) + ArgumentNullException.ThrowIfNull(providerIds); + + var existenceOnly = new List<string>(); + var specificValues = new List<string>(); + foreach (var (provider, value) in providerIds) { - var value = oneOf[0]; - if (typeof(TProperty).IsValueType) + if (string.IsNullOrEmpty(value)) { - return Expression.Lambda<Func<TEntity, bool>>(Expression.Equal(property.Body, Expression.Constant(value)), parameter); + existenceOnly.Add(provider); } else { - return Expression.Lambda<Func<TEntity, bool>>(Expression.ReferenceEqual(property.Body, Expression.Constant(value)), parameter); + specificValues.Add(provider + ":" + value); } } - var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); + if (existenceOnly.Count == 0 && specificValues.Count == 0) + { + return baseQuery; + } - // Always wrap the collection in EF.Parameter so EF Core caches a single compiled plan and reuses it across calls. - return Expression.Lambda<Func<TEntity, bool>>( - Expression.Call( - null, - containsMethodInfo, - Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), - property.Body), + var predicate = ProviderPredicate(existenceOnly, specificValues); + + // NOT EXISTS rather than NOT IN: the latter yields no rows at all if the subquery can produce NULL. + return invert + ? baseQuery.Where(e => !e.Provider!.AsQueryable().Any(predicate)) + : baseQuery.Where(e => e.Provider!.AsQueryable().Any(predicate)); + } + + private static Expression<Func<BaseItemProvider, bool>> ProviderPredicate( + IReadOnlyList<string> existenceOnly, + IReadOnlyList<string> specificValues) + { + var byProvider = existenceOnly.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId); + var byPair = specificValues.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId + ":" + p.ProviderValue); + + // Both builders mint their own parameter; rebind so the two bodies can share one lambda. + var parameter = byProvider.Parameters[0]; + var reboundPair = ParameterReplacer.Replace<Func<BaseItemProvider, bool>, Func<BaseItemProvider, bool>>(byPair, byPair.Parameters[0], parameter); + + return Expression.Lambda<Func<BaseItemProvider, bool>>( + Expression.OrElse(byProvider.Body, reboundPair.Body), parameter); } diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index 5e7e2090cd..55068a5e7e 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -2,13 +2,17 @@ <PropertyGroup> <TargetFramework>net10.0</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> + <ItemGroup> + <Compile Include="..\..\SharedVersion.cs" /> + </ItemGroup> + <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.MediaEncoding.Keyframes</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> @@ -22,10 +26,4 @@ <PackageReference Include="NEbml" /> </ItemGroup> - <ItemGroup> - <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> - <_Parameter1>Jellyfin.MediaEncoding.Keyframes.Tests</_Parameter1> - </AssemblyAttribute> - </ItemGroup> - </Project> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Properties/AssemblyInfo.cs b/src/Jellyfin.MediaEncoding.Keyframes/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..70cc8a4648 --- /dev/null +++ b/src/Jellyfin.MediaEncoding.Keyframes/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Jellyfin.MediaEncoding.Keyframes.Tests")] diff --git a/tests/Jellyfin.Controller.Tests/Drawing/ImageHelperTests.cs b/tests/Jellyfin.Controller.Tests/Drawing/ImageHelperTests.cs new file mode 100644 index 0000000000..571cb7f0d4 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Drawing/ImageHelperTests.cs @@ -0,0 +1,64 @@ +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Model.Drawing; +using Xunit; + +namespace Jellyfin.Controller.Tests.Drawing; + +public static class ImageHelperTests +{ + [Fact] + public static void GetNewImageSize_ExplicitSizeLargerThanSource_ClampsToSource() + { + // Regression test for https://github.com/jellyfin/jellyfin/issues/17056: the caller-supplied + // width/height were used verbatim, so a single request could ask for a 23100x23100 encode. + var options = new ImageProcessingOptions { Width = 23100, Height = 23100 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(336, newSize.Width); + Assert.Equal(336, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_WidthLargerThanSource_ClampsToSource() + { + var options = new ImageProcessingOptions { Width = 10000 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(600, newSize.Width); + Assert.Equal(336, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_FillLargerThanSource_ClampsToSource() + { + // ResizeFill already refused to upscale; this pins that behaviour. + var options = new ImageProcessingOptions { FillWidth = 23100, FillHeight = 23100 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(600, newSize.Width); + Assert.Equal(336, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_SmallerThanSource_StillDownscales() + { + var options = new ImageProcessingOptions { MaxWidth = 300 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(300, newSize.Width); + Assert.Equal(168, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_NoSizeRequested_ReturnsSource() + { + var newSize = ImageHelper.GetNewImageSize(new ImageProcessingOptions(), new ImageDimensions(600, 336)); + + Assert.Equal(600, newSize.Width); + Assert.Equal(336, newSize.Height); + } +} diff --git a/tests/Jellyfin.Model.Tests/Drawing/DrawingUtilsTests.cs b/tests/Jellyfin.Model.Tests/Drawing/DrawingUtilsTests.cs new file mode 100644 index 0000000000..473b07a8a1 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Drawing/DrawingUtilsTests.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Model.Drawing; +using Xunit; + +namespace Jellyfin.Model.Drawing; + +public static class DrawingUtilsTests +{ + [Theory] + // Already inside the box, returned untouched. + [InlineData(600, 336, 1920, 1080, 600, 336)] + [InlineData(1920, 1080, 1920, 1080, 1920, 1080)] + // Scaled down uniformly, requested aspect ratio preserved. + [InlineData(23100, 23100, 1920, 1080, 1080, 1080)] + [InlineData(3840, 2160, 1920, 1080, 1920, 1080)] + [InlineData(1200, 400, 600, 336, 600, 200)] + // Extreme ratios still produce at least one pixel per axis. + [InlineData(10000, 1, 100, 100, 100, 1)] + // Degenerate inputs are passed through rather than dividing by zero. + [InlineData(600, 336, 0, 0, 600, 336)] + [InlineData(0, 0, 1920, 1080, 0, 0)] + public static void ScaleDownToFit_Bounds_WithoutUpscaling(int width, int height, int boxWidth, int boxHeight, int expectedWidth, int expectedHeight) + { + var scaled = DrawingUtils.ScaleDownToFit(new ImageDimensions(width, height), new ImageDimensions(boxWidth, boxHeight)); + + Assert.Equal(expectedWidth, scaled.Width); + Assert.Equal(expectedHeight, scaled.Height); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs index d18f8c6cff..6a3dcab57a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs @@ -9,6 +9,7 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Providers; @@ -205,6 +206,84 @@ public class DtoServiceImageInheritanceTests libraryManager.Verify(x => x.GetPeople(It.IsAny<BaseItem>()), Times.Never); } + [Fact] + public void GetBaseItemDtos_Videos_ResolveMediaSourceCountFromBatch_WithoutPerItemLookup() + { + static Movie MakeMovie() => new Movie + { + Id = Guid.NewGuid(), + Name = "Movie", + ImageInfos = [] + }; + + var movieOne = MakeMovie(); + var movieTwo = MakeMovie(); + + var libraryManager = new Mock<ILibraryManager>(); + + // DtoService detects which videos own alternate versions in ONE batch + // (GetItemIdsWithAlternateVersions) before the per-item loop. Videos absent from that set have a + // single media source, so the per-item GetLinkedAlternateVersions/GetLocalAlternateVersionIds + // queries (the N+1) must be skipped entirely. Here neither movie has alternate versions. + libraryManager + .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new HashSet<Guid>()); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] }; + var dtos = dtoService.GetBaseItemDtos([movieOne, movieTwo], options); + + Assert.Equal(2, dtos.Count); + + // A single media source is the default, so the count is left unset (the client treats null as one). + foreach (var dto in dtos) + { + Assert.Null(dto.MediaSourceCount); + } + + // The alternate-version check is batched once for the whole set, and the per-item lookups are + // never reached because the batch already ruled out alternate versions. + libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + libraryManager.Verify(x => x.GetLinkedAlternateVersions(It.IsAny<Video>()), Times.Never); + libraryManager.Verify(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()), Times.Never); + } + + [Fact] + public void GetBaseItemDtos_VideoInAlternateVersionBatch_ResolvesRealCount() + { + var movie = new Movie + { + Id = Guid.NewGuid(), + Name = "Movie", + ImageInfos = [] + }; + + var libraryManager = new Mock<ILibraryManager>(); + + // This movie IS in the batch set, so the fast path must not short-circuit it: the per-item + // lookups still run and the count is computed exactly as it was before batching. Two linked + // alternate versions plus the movie itself is a count of three. + libraryManager + .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new HashSet<Guid> { movie.Id }); + libraryManager + .Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())) + .Returns([new Movie { Id = Guid.NewGuid() }, new Movie { Id = Guid.NewGuid() }]); + libraryManager + .Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())) + .Returns([]); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] }; + var dtos = dtoService.GetBaseItemDtos([movie], options); + + Assert.Single(dtos); + Assert.Equal(3, dtos[0].MediaSourceCount); + libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + } + private static DtoService BuildDtoService(BaseItem displayParent) { var libraryManager = new Mock<ILibraryManager>(); @@ -231,6 +310,10 @@ public class DtoServiceImageInheritanceTests .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path); + // Video.IsActiveRecording() dereferences this static during DTO building. + Video.RecordingsManager = recordingsManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + return new DtoService( logger.Object, libraryManager.Object, |
