diff options
18 files changed, 601 insertions, 137 deletions
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index c045f8558c..3db8265f6e 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1984,18 +1984,10 @@ namespace Emby.Server.Implementations.Library { // Playlists and BoxSets store their contents in LinkedChildren and never // populate AncestorIds for those items, so a recursive AncestorIds query - // would return zero rows. Resolve to the linked child IDs up front and - // route through the existing indexed ItemIds filter. - query.ItemIds = folder.LinkedChildren - .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) - .Select(lc => lc.ItemId!.Value) - .ToArray(); - - // Empty linked-children should still return empty rather than scanning everything. - if (query.ItemIds.Length == 0) - { - query.ItemIds = [Guid.NewGuid()]; - } + // would return zero rows. Filter by the descendant set instead, which follows + // the links and keeps descending, so a linked folder contributes what is below + // it as well - the episodes of a Series added to a collection, for example. + query.DescendantOfId = folder.Id; } else { diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index dbfeabd88e..c41cedf98a 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -100,14 +100,14 @@ "TaskAudioNormalization": "Garso normalizavimas", "TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.", "TaskExtractMediaSegments": "Medijos segmentų nuskaitymas", - "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus", + "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus", "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.", "TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą", "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.", - "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius", + "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus", "CleanupUserDataTask": "Naudotojo duomenų valymo užduotis", "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).", - "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}", + "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}", "NameExtraBehindTheScenes": "Užkulisiuose", "NameExtraClip": "Klipas", "NameExtraDeletedScene": "Ištrinta scena", diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index afb27ddf9e..42835d7ad0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -109,6 +109,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var dupQuery = context.Peoples .GroupBy(e => new { e.Name, e.PersonType }) .Where(e => e.Count() > 1) + .OrderBy(e => e.Key.Name) + .ThenBy(e => e.Key.PersonType) .Select(e => e.Select(f => f.Id).ToArray()); var total = dupQuery.Count(); diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index f19ca77818..fdbbace1e7 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -126,6 +126,12 @@ public class ArtistsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); @@ -193,31 +199,7 @@ public class ArtistsController : BaseJellyfinApiController var result = _libraryManager.GetArtists(query); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> @@ -298,6 +280,12 @@ public class ArtistsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); @@ -365,31 +353,7 @@ public class ArtistsController : BaseJellyfinApiController var result = _libraryManager.GetAlbumArtists(query); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 39c3f5abcf..18a8b67be2 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -97,6 +97,12 @@ public class GenresController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController result = _libraryManager.GetGenres(query); } - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 7af44f8bd6..4ebf914895 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -98,6 +98,12 @@ public class MusicGenresController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -134,8 +140,7 @@ public class MusicGenresController : BaseJellyfinApiController var result = _libraryManager.GetMusicGenres(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index a8feb206a4..5bac850859 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -92,6 +92,12 @@ public class StudiosController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -126,8 +132,7 @@ public class StudiosController : BaseJellyfinApiController } var result = _libraryManager.GetStudios(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index e5df873f5b..c4851091c1 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController if (!string.IsNullOrEmpty(fallbackFontPath)) { var fontFile = _fileSystem.GetFiles(fallbackFontPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); var fileSize = fontFile?.Length; if (fontFile is not null && fileSize is not null && fileSize > 0) diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index d14c3a9343..4c5ed16015 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -156,7 +156,6 @@ public static class RequestHelpers QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, DtoOptions dtoOptions, IDtoService dtoService, - bool includeItemTypes, User? user) { var dtos = result.Items.Select(i => @@ -164,7 +163,7 @@ public static class RequestHelpers var (baseItem, counts) = i; var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (includeItemTypes) + if (counts is not null) { dto.ChildCount = counts.ItemCount; dto.ProgramCount = counts.ProgramCount; @@ -175,6 +174,7 @@ public static class RequestHelpers dto.AlbumCount = counts.AlbumCount; dto.SongCount = counts.SongCount; dto.ArtistCount = counts.ArtistCount; + dto.MusicVideoCount = counts.MusicVideoCount; } return dto; diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 5a41619390..2ff8131ade 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -246,14 +246,20 @@ public sealed partial class BaseItemRepository } result.StartIndex = filter.StartIndex ?? 0; - if (filter.IncludeItemTypes.Length > 0) + var page = query.AsEnumerable().Where(e => e is not null).ToList(); + + if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts)) { - var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes); + var pageCleanNames = page + .Where(e => !string.IsNullOrEmpty(e.CleanName)) + .Select(e => e.CleanName!) + .Distinct() + .ToList(); + + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames); result.Items = [ - .. query - .AsEnumerable() - .Where(e => e is not null) + .. page .Select(e => { var item = DeserializeBaseItem(e, filter.SkipDeserialization); @@ -268,9 +274,7 @@ public sealed partial class BaseItemRepository { result.Items = [ - .. query - .AsEnumerable() - .Where(e => e != null) + .. page .Select(e => DeserializeBaseItem(e, filter.SkipDeserialization)) .Where(item => item != null) .Select(item => (item!, (ItemCounts?)null)) @@ -281,14 +285,22 @@ public sealed partial class BaseItemRepository } private Dictionary<string, ItemCounts> BuildItemCountsByCleanName( - Database.Implementations.JellyfinDbContext context, + JellyfinDbContext context, InternalItemsQuery filter, - IReadOnlyList<ItemValueType> itemValueTypes) + IReadOnlyList<ItemValueType> itemValueTypes, + IReadOnlyList<string> cleanNames) { - var typeSubQuery = new InternalItemsQuery(filter.User) + var countsByCleanName = new Dictionary<string, ItemCounts>(); + if (cleanNames.Count == 0) + { + return countsByCleanName; + } + + // The counts describe everything the value is attached to, not only the types the list was + // filtered down to. + var scopeQuery = new InternalItemsQuery(filter.User) { ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, MediaTypes = filter.MediaTypes, AncestorIds = filter.AncestorIds, ExcludeItemIds = filter.ExcludeItemIds, @@ -298,33 +310,51 @@ public sealed partial class BaseItemRepository IsPlayed = filter.IsPlayed }; - var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery); + var valueLinks = context.ItemValuesMap + .AsNoTracking() + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo]; + var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram]; var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var itemIds = itemCountQuery.Select(e => e.Id); // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) // Instead, start from ItemValueMaps and join with BaseItems. - var rawCounts = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Where(ivm => itemIds.Contains(ivm.ItemId)) + var rawCounts = valueLinks .Join( - context.BaseItems, + scopedItems, ivm => ivm.ItemId, e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) - .GroupBy(x => new { x.CleanName, x.Type }) - .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) - .AsEnumerable(); + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId }) + .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId }) + .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() }) + .ToList(); + + // Only studios and genres pass down from a series to its episodes; an artist credit does not. + var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre); + var episodeCounts = inheritsToEpisodes + ? BuildEpisodeCountsByCleanName( + scopedItems, + valueLinks, + rawCounts + .Where(x => x.Type == episodeTypeName) + .Select(x => (x.CleanName, x.SeriesId, x.Count)) + .ToList(), + seriesTypeName, + episodeTypeName) + : rawCounts + .Where(x => x.Type == episodeTypeName) + .GroupBy(x => x.CleanName) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Count)); - var countsByCleanName = new Dictionary<string, ItemCounts>(); foreach (var group in rawCounts.GroupBy(x => x.CleanName)) { var counts = new ItemCounts(); @@ -334,10 +364,6 @@ public sealed partial class BaseItemRepository { counts.SeriesCount += row.Count; } - else if (row.Type == episodeTypeName) - { - counts.EpisodeCount += row.Count; - } else if (row.Type == movieTypeName) { counts.MovieCount += row.Count; @@ -350,6 +376,14 @@ public sealed partial class BaseItemRepository { counts.ArtistCount += row.Count; } + else if (row.Type == musicVideoTypeName) + { + counts.MusicVideoCount += row.Count; + } + else if (row.Type == programTypeName) + { + counts.ProgramCount += row.Count; + } else if (row.Type == audioTypeName) { counts.SongCount += row.Count; @@ -360,9 +394,72 @@ public sealed partial class BaseItemRepository } } + // Episodes are counted separately: the value is usually only written on the series. + counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key); + counts.ItemCount = counts.TotalItemCount(); countsByCleanName[group.Key] = counts; } + // A value carried by nothing but the episodes below a tagged series has no row of its own. + foreach (var (cleanName, episodeCount) in episodeCounts) + { + if (!countsByCleanName.ContainsKey(cleanName)) + { + countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount }; + } + } + return countsByCleanName; } + + private static Dictionary<string, int> BuildEpisodeCountsByCleanName( + IQueryable<BaseItemEntity> scopedItems, + IQueryable<ItemValueMap> valueLinks, + IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes, + string seriesTypeName, + string episodeTypeName) + { + // Resolved in steps rather than as one union: each of these drives off an index, while the + // single-statement form leaves SQLite free to scan every episode in the library instead. + var taggedSeries = valueLinks + .Join( + scopedItems.Where(e => e.Type == seriesTypeName), + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .ToList(); + + var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray(); + var episodesPerSeries = seriesIds.Length == 0 + ? [] + : scopedItems + .Where(e => e.Type == episodeTypeName && e.SeriesId != null) + .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value) + .GroupBy(e => e.SeriesId!.Value) + .Select(g => new { SeriesId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeriesId, x => x.Count); + + var episodeCounts = new Dictionary<string, int>(); + var seriesByCleanName = new Dictionary<string, HashSet<Guid>>(); + foreach (var group in taggedSeries.GroupBy(x => x.CleanName)) + { + var series = group.Select(x => x.SeriesId).ToHashSet(); + seriesByCleanName[group.Key] = series; + episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id)); + } + + foreach (var (cleanName, seriesId, count) in taggedEpisodes) + { + if (seriesId is not null + && seriesByCleanName.TryGetValue(cleanName, out var series) + && series.Contains(seriesId.Value)) + { + continue; + } + + episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count; + } + + return episodeCounts; + } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 1e30f0164e..d635b38df5 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1091,6 +1091,12 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); } + if (filter.DescendantOfId.HasValue) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value); + baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id)); + } + if (filter.LinkedChildAncestorIds.Length > 0) { // Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids. diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 14b120363f..704dc31fd0 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -249,11 +249,51 @@ public class ItemCountService : IItemCountService } } + if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre + && relatedItemKinds.Contains(BaseItemKind.Episode) + && relatedItemKinds.Contains(BaseItemKind.Series)) + { + var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount); + totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount; + result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount; + } + result.ItemCount = totalCount; return result; } + private int CountEpisodesOfTaggedSeries( + JellyfinDbContext context, + IQueryable<BaseItemEntity> taggedItems, + InternalItemsQuery accessFilter, + out int unrelatedEpisodeCount) + { + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id); + unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName + && (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value))); + + // Materialised so the episode count drives off IX_BaseItems_SeriesId. + var seriesIds = taggedItems + .Where(e => e.Type == seriesTypeName) + .Select(e => e.Id) + .ToArray(); + + if (seriesIds.Length == 0) + { + return 0; + } + + var episodes = context.BaseItems.AsNoTracking() + .Where(e => e.Type == episodeTypeName && e.SeriesId != null) + .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value); + + return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count(); + } + private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds) => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id)); diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index 4a6e74c229..8f239007d8 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -22,6 +22,11 @@ namespace Jellyfin.Server.Migrations.Routines; [JellyfinMigrationBackup(JellyfinDb = true)] internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { + private const int ParseProgressLogStep = 25_000; + private const int FileCheckProgressLogStep = 10_000; + private const int ResolveProgressLogStep = 10_000; + private const int DeleteProgressLogStep = 25; + private readonly ILogger<MigrateLinkedChildren> _logger; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly ILibraryManager _libraryManager; @@ -85,7 +90,6 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var droppedChildren = 0; var linkedChildrenToAdd = new List<LinkedChildEntity>(); var processedCount = 0; - const int progressLogStep = 1000; var totalItems = itemsWithData.Count; foreach (var item in itemsWithData) @@ -95,7 +99,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine continue; } - if (processedCount > 0 && processedCount % progressLogStep == 0) + if (processedCount > 0 && processedCount % ParseProgressLogStep == 0) { _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems); } @@ -311,11 +315,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count); - var itemsToDelete = wrongTypeChildIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items"); _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted); } @@ -342,11 +342,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count); - var itemsToDelete = orphanedVersionIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems"); _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted); } @@ -371,11 +367,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count); - var itemsToDelete = orphanedIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries"); _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted); } @@ -427,8 +419,23 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var skippedUnrootedItems = 0; var staleIds = new List<Guid>(); + var checkedCount = 0; + _logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count); + foreach (var item in itemsWithPaths) { + // A miss on offline storage can block for the mount timeout, so report while scanning. + if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0) + { + _logger.LogInformation( + "Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.", + checkedCount, + itemsWithPaths.Count, + staleIds.Count); + } + + checkedCount++; + // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths var path = _appHost.ExpandVirtualPath(item.Path!); @@ -482,16 +489,47 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count); - var itemsToDelete = staleIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(staleIds, "items with missing files"); _logger.LogInformation("Removed {Count} stale items.", deleted); } - private int DeleteItems(IReadOnlyCollection<BaseItem> items) + private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description) + { + if (ids.Count == 0) + { + return 0; + } + + return DeleteItems(ResolveItems(ids, description), description); + } + + private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description) + { + // Each lookup is a separate repository read; cached ones are fast, so this only reports + // once a set is large enough for the reads to add up to a noticeable stretch. + var items = new List<BaseItem>(ids.Count); + var processed = 0; + foreach (var id in ids) + { + if (processed > 0 && processed % ResolveProgressLogStep == 0) + { + _logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.Count); + } + + processed++; + + var item = _libraryManager.GetItemById(id); + if (item is not null) + { + items.Add(item); + } + } + + return items; + } + + private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description) { if (items.Count == 0) { @@ -500,8 +538,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false }; var deleted = 0; + var processed = 0; foreach (var item in items) { + if (processed > 0 && processed % DeleteProgressLogStep == 0) + { + _logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, items.Count); + } + + processed++; + try { _libraryManager.DeleteItem(item, options); diff --git a/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs new file mode 100644 index 0000000000..e665725ced --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Migrations.Stages; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Enables the local similarity providers on libraries that predate the similar items settings. +/// </summary> +[JellyfinMigration("2026-08-31T10:00:00", nameof(EnableLocalSimilarityProviders), Stage = JellyfinMigrationStageTypes.AppInitialisation)] +internal class EnableLocalSimilarityProviders : IAsyncMigrationRoutine +{ + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly ILogger _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EnableLocalSimilarityProviders"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="startupLogger">The startup logger for Startup UI integration.</param> + /// <param name="logger">The logger.</param> + public EnableLocalSimilarityProviders( + ILibraryManager libraryManager, + IProviderManager providerManager, + IStartupLogger<EnableLocalSimilarityProviders> startupLogger, + ILogger<EnableLocalSimilarityProviders> logger) + { + _libraryManager = libraryManager; + _providerManager = providerManager; + _logger = startupLogger.With(logger); + } + + /// <inheritdoc /> + public Task PerformAsync(CancellationToken cancellationToken) + { + // Libraries created before similar items became configurable have an empty provider list, + // which the library editor renders as "everything unchecked" instead of falling back to the + // defaults it uses for new libraries. Seed the local providers so they stay enabled. + var localProvidersByType = GetLocalProvidersByItemType(); + if (localProvidersByType.Count == 0) + { + return Task.CompletedTask; + } + + foreach (var virtualFolder in _libraryManager.GetVirtualFolders(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + EnableLocalProviders(virtualFolder, localProvidersByType); + } + + return Task.CompletedTask; + } + + private void EnableLocalProviders(VirtualFolderInfo virtualFolder, Dictionary<string, string[]> localProvidersByType) + { + var options = virtualFolder.LibraryOptions; + if (options?.TypeOptions is null || options.TypeOptions.Length == 0) + { + return; + } + + // Some virtual folders don't have a proper item id. + if (!Guid.TryParse(virtualFolder.ItemId, out var folderId)) + { + return; + } + + var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId); + if (collectionFolder is null) + { + _logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId); + return; + } + + var changed = false; + foreach (var typeOptions in options.TypeOptions) + { + changed |= EnableLocalProviders(typeOptions, localProvidersByType, virtualFolder.Name); + } + + if (changed) + { + collectionFolder.UpdateLibraryOptions(options); + } + } + + private bool EnableLocalProviders(TypeOptions typeOptions, Dictionary<string, string[]> localProvidersByType, string libraryName) + { + if (typeOptions.Type is null || !localProvidersByType.TryGetValue(typeOptions.Type, out var localProviders)) + { + return false; + } + + var enabled = typeOptions.SimilarItemProviders ?? []; + var missing = localProviders.Where(name => !enabled.Contains(name, StringComparer.OrdinalIgnoreCase)).ToArray(); + if (missing.Length == 0) + { + return false; + } + + // Local providers rank ahead of remote ones, and the enabled list doubles as the + // priority order when no explicit order was saved. + typeOptions.SimilarItemProviders = [.. missing, .. enabled]; + if (typeOptions.SimilarItemProviderOrder is { Length: > 0 } order) + { + typeOptions.SimilarItemProviderOrder = [.. missing, .. order]; + } + + _logger.LogInformation("Enabled local similarity providers {Providers} for '{ItemType}' in library '{LibraryName}'.", missing, typeOptions.Type, libraryName); + return true; + } + + private Dictionary<string, string[]> GetLocalProvidersByItemType() + { + var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase); + + foreach (var summary in _providerManager.GetAllMetadataPlugins()) + { + var names = summary.Plugins + .Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider) + .Select(p => p.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (names.Length > 0) + { + result[summary.ItemType] = names; + } + } + + return result; + } +} diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index e85f86b72f..0e5a5047cd 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -103,6 +103,7 @@ namespace MediaBrowser.Controller.Entities || SubtitleLanguages.Count > 0 || LinkedChildAncestorIds.Length > 0 || AncestorIds.Length > 0 + || DescendantOfId.HasValue || IsFavorite.HasValue || IsFavoriteOrLiked.HasValue || IsLiked.HasValue @@ -368,6 +369,13 @@ namespace MediaBrowser.Controller.Entities /// </summary> public Guid[] LinkedChildAncestorIds { get; set; } + /// <summary> + /// Gets or sets the id of a folder whose descendants the items must be part of. + /// Unlike <see cref="AncestorIds"/> this also follows the linked children of BoxSets and + /// Playlists, so it reaches the items below a linked folder (a Series' episodes, for example). + /// </summary> + public Guid? DescendantOfId { get; set; } + public Guid[] TopParentIds { get; set; } public CollectionType?[] PresetViews { get; set; } diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html index dec21d1b42..d485fe555a 100644 --- a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html @@ -7,7 +7,6 @@ <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select"> <div data-role="content"> <div class="content-primary"> - <img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" /> <h1>ListenBrainz</h1> <p>Get similar artist recommendations from ListenBrainz Labs.</p> <form class="configForm"> @@ -18,12 +17,12 @@ <div class="selectContainer"> <label class="selectLabel" for="algorithm">Similarity Algorithm</label> <select is="emby-select" id="algorithm" class="emby-select-withcolor"> - <option value="0" selected>~5 years / 1825 days (Recommended)</option> - <option value="1">~5 years / 1800 days</option> - <option value="2">~20 years / 7500 days</option> - <option value="3">~20 years / 7500 days (high contribution)</option> - <option value="4">~25 years / 9000 days</option> - <option value="5">~75 days (recent)</option> + <option value="SessionBased1825Days" selected>~5 years / 1825 days (Recommended)</option> + <option value="SessionBased1800Days">~5 years / 1800 days</option> + <option value="SessionBased7500Days">~20 years / 7500 days</option> + <option value="SessionBased7500DaysHighContribution">~20 years / 7500 days (high contribution)</option> + <option value="SessionBased9000Days">~25 years / 9000 days</option> + <option value="SessionBased75Days">~75 days (recent)</option> </select> <div class="fieldDescription">The algorithm used for artist similarity calculation.</div> </div> @@ -52,13 +51,14 @@ </div> <script type="text/javascript"> var ListenBrainzPluginConfig = { - uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e" + uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e", + defaultAlgorithm: "SessionBased1825Days" }; document.querySelector('.configPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); - document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' }); + ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { var labsServer = document.querySelector('#labsServer'); labsServer.value = config.LabsServer; @@ -67,7 +67,13 @@ cancelable: false })); - document.querySelector('#algorithm').value = config.Algorithm; + // The API serialises the algorithm as its enum name, so an unknown value here + // means a config written by an older build; fall back to the default. + var algorithm = document.querySelector('#algorithm'); + algorithm.value = config.Algorithm; + if (!algorithm.value) { + algorithm.value = ListenBrainzPluginConfig.defaultAlgorithm; + } var rateLimit = document.querySelector('#rateLimit'); rateLimit.value = config.RateLimit; @@ -93,7 +99,7 @@ ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { config.LabsServer = document.querySelector('#labsServer').value; - config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10); + config.Algorithm = document.querySelector('#algorithm').value; config.RateLimit = document.querySelector('#rateLimit').value; config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index b3a67189bb..c94a6455bc 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -128,6 +128,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <summary> /// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching. /// </summary> - public int SimilarItemsCacheDays { get; set; } = 7; + public int SimilarItemsCacheDays { get; set; } = 90; } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs new file mode 100644 index 0000000000..0ca11eb58d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a +/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a +/// linked folder are only reachable by following the link and then the ancestor chain. +/// </summary> +public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _collection = Guid.NewGuid(); + private readonly Guid _series = Guid.NewGuid(); + private readonly Guid _season = Guid.NewGuid(); + private readonly Guid _episode = Guid.NewGuid(); + + // A movie the collection links directly, so the direct-child case is covered alongside the nested one. + private readonly Guid _collectionMovie = Guid.NewGuid(); + + // In the same library but outside the collection, as the control the assertions are read against. + private readonly Guid _otherSeries = Guid.NewGuid(); + private readonly Guid _otherEpisode = Guid.NewGuid(); + + public BaseItemRepositoryDescendantFilterTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void DescendantOfId_ReachesEpisodesOfALinkedSeries() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Episode] + }); + + Assert.Equal([_episode], ids); + } + + [Fact] + public void DescendantOfId_ReturnsEveryLevelBelowTheCollection() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet(); + + Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order()); + } + + [Fact] + public void DescendantOfId_KeepsDirectlyLinkedChildren() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal([_collectionMovie], ids); + } + + [Fact] + public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() }); + + Assert.Empty(ids); + } + + private void Seed(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" }); + context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" }); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. + AddAncestors(context, _series, _library); + AddAncestors(context, _season, _series, _library); + AddAncestors(context, _episode, _season, _series, _library); + AddAncestors(context, _collectionMovie, _library); + AddAncestors(context, _otherSeries, _library); + AddAncestors(context, _otherEpisode, _otherSeries, _library); + + AddLink(context, _series, 0); + AddLink(context, _collectionMovie, 1); + + context.SaveChanges(); + } + + private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _collection, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + } +} |
