From 5996c4afce11249804d24f1caa3a99b390543c4d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 17 Jan 2026 17:10:07 +0100 Subject: Complete LinkedChildren integration and batch DTO optimizations This commit integrates remaining performance changes: - Add batch user data fetching in DtoService to reduce N+1 queries - Add GetNextUpEpisodesBatch in TVSeriesManager for efficient batch retrieval - Update Video/Movie/BoxSet to use LibraryManager for alternate versions - Transition LinkedChild to use ItemId instead of Path (obsolete Path/LibraryItemId) - Update providers and controllers for LinkedChildren-based references - Add NextUpEpisodeBatchResult for batched episode queries - Integrate IDescendantQueryProvider in SqliteDatabaseProvider --- .../Persistence/IItemRepository.cs | 72 ++++++++++++++++++++++ .../Persistence/NextUpEpisodeBatchResult.cs | 38 ++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs (limited to 'MediaBrowser.Controller/Persistence') diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index bf80b7d0a8..f7ed39730e 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -87,6 +87,21 @@ public interface IItemRepository /// The list of keys. IReadOnlyList GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); + /// + /// Gets next up episodes for multiple series in a single batched query. + /// Returns the last watched episode, next unwatched episode, specials, and next played episode for each series. + /// + /// The query filter. + /// The series presentation unique keys to query. + /// Whether to include specials (ParentIndexNumber = 0) in the results. + /// Whether to include watched episodes for rewatching mode. + /// A dictionary mapping series key to batch result containing episodes needed for NextUp calculation. + IReadOnlyDictionary GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); + /// /// Updates the inherited values. /// @@ -132,10 +147,67 @@ public interface IItemRepository /// A value indicating whever all children has been played. bool GetIsPlayed(User user, Guid id, bool recursive); + /// + /// Gets the count of played items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// + /// The query filter containing user access settings. + /// The ancestor item id. + /// The count of played descendant items. + int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId); + + /// + /// Gets the total count of items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// + /// The query filter containing user access settings. + /// The ancestor item id. + /// The total count of descendant items. + int GetTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// + /// Gets both the played count and total count of items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// + /// The query filter containing user access settings. + /// The ancestor item id. + /// A tuple containing (Played count, Total count). + (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// + /// Gets both the played count and total count of items that are linked children of the specified parent. + /// Uses the LinkedChildren table for BoxSets, Playlists, etc. + /// Applies user access filtering (library access, parental controls, tags). + /// + /// The query filter containing user access settings. + /// The parent item id (BoxSet, Playlist, etc.). + /// A tuple containing (Played count, Total count). + (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId); + + /// + /// Gets the IDs of linked children for the specified parent. + /// + /// The parent item ID. + /// Optional child type filter (e.g., LocalAlternateVersion, LinkedAlternateVersion). + /// List of child item IDs. + IReadOnlyList GetLinkedChildrenIds(Guid parentId, int? childType = null); + /// /// Gets all artist matches from the db. /// /// The names of the artists. /// A map of the artist name and the potential matches. IReadOnlyDictionary FindArtists(IReadOnlyList artistNames); + + /// + /// Batch-fetches child counts for multiple parent folders. + /// Returns the count of immediate children (non-recursive) for each parent. + /// + /// The list of parent folder IDs. + /// The user ID for access filtering. + /// Dictionary mapping parent ID to child count. + Dictionary GetChildCountBatch(IReadOnlyList parentIds, Guid? userId); } diff --git a/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs new file mode 100644 index 0000000000..f5b09498b9 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// +/// Result of a batched NextUp query for a single series. +/// +public sealed class NextUpEpisodeBatchResult +{ + /// + /// Gets or sets the last watched episode (highest season/episode that is played). + /// + public BaseItem? LastWatched { get; set; } + + /// + /// Gets or sets the next unwatched episode after the last watched position. + /// + public BaseItem? NextUp { get; set; } + + /// + /// Gets or sets specials that may air between episodes. + /// Only populated when includeSpecials is true. + /// + public IReadOnlyList? Specials { get; set; } + + /// + /// Gets or sets the last watched episode for rewatching mode (most recently played). + /// Only populated when includeWatchedForRewatching is true. + /// + public BaseItem? LastWatchedForRewatching { get; set; } + + /// + /// Gets or sets the next played episode for rewatching mode. + /// Only populated when includeWatchedForRewatching is true. + /// + public BaseItem? NextPlayedForRewatching { get; set; } +} -- cgit v1.2.3 From 694db80d4c8e83ff381af56d2a3dde29e0855c3d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 30 Jan 2026 21:58:24 +0100 Subject: Reroute on version removal --- .../Library/LibraryManager.cs | 15 +++++++++ Jellyfin.Api/Controllers/VideosController.cs | 3 ++ .../Item/BaseItemRepository.cs | 39 ++++++++++++++++++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 9 +++++ .../Persistence/IItemRepository.cs | 17 ++++++++++ 5 files changed, 83 insertions(+) (limited to 'MediaBrowser.Controller/Persistence') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2acfd68c36..830c918541 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -428,6 +428,9 @@ namespace Emby.Server.Implementations.Library newPrimary.SetPrimaryVersionId(null); newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + // Re-route playlist/collection references from deleted primary to new primary + _itemRepository.RerouteLinkedChildren(video.Id, newPrimary.Id); + // Update remaining alternates to point to new primary foreach (var alternate in alternateVersions.Skip(1)) { @@ -436,6 +439,12 @@ namespace Emby.Server.Implementations.Library } } } + else if (item is Video alternateVideo && !string.IsNullOrEmpty(alternateVideo.PrimaryVersionId) + && Guid.TryParse(alternateVideo.PrimaryVersionId, out var primaryId)) + { + // If deleting an alternate version, re-route references to its primary + _itemRepository.RerouteLinkedChildren(alternateVideo.Id, primaryId); + } var children = item.IsFolder ? ((Folder)item).GetRecursiveChildren(false) @@ -3480,5 +3489,11 @@ namespace Emby.Server.Implementations.Library _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); RemoveContentTypeOverrides(path); } + + /// + public int RerouteLinkedChildReferences(Guid fromChildId, Guid toChildId) + { + return _itemRepository.RerouteLinkedChildren(fromChildId, toChildId); + } } } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 2237e36b5f..1c8b695458 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -222,6 +222,9 @@ public class VideosController : BaseJellyfinApiController await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + // Re-route any playlist/collection references from this item to the primary + _libraryManager.RerouteLinkedChildReferences(item.Id, primaryVersion.Id); + if (!alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(item.Id))) { alternateVersionsOfPrimary.Add(new LinkedChild diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 9e6b100b66..22178e57f7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -3843,4 +3843,43 @@ public sealed class BaseItemRepository return result; } + + /// + public IReadOnlyList GetManualLinkedParentIds(Guid childId) + { + using var context = _dbProvider.CreateDbContext(); + return context.LinkedChildren + .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual) + .Select(lc => lc.ParentId) + .Distinct() + .ToList(); + } + + /// + public int RerouteLinkedChildren(Guid fromChildId, Guid toChildId) + { + using var context = _dbProvider.CreateDbContext(); + + // Get parents that already reference toChildId (to avoid duplicates) + var parentsWithTarget = context.LinkedChildren + .Where(lc => lc.ChildId == toChildId && lc.ChildType == DbLinkedChildType.Manual) + .Select(lc => lc.ParentId) + .ToHashSet(); + + // Update references that won't create duplicates + var updated = context.LinkedChildren + .Where(lc => lc.ChildId == fromChildId + && lc.ChildType == DbLinkedChildType.Manual + && !parentsWithTarget.Contains(lc.ParentId)) + .ExecuteUpdate(s => s.SetProperty(e => e.ChildId, toChildId)); + + // Remove references that would be duplicates + context.LinkedChildren + .Where(lc => lc.ChildId == fromChildId + && lc.ChildType == DbLinkedChildType.Manual + && parentsWithTarget.Contains(lc.ParentId)) + .ExecuteDelete(); + + return updated; + } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index c19d15d85f..48859de04b 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -714,5 +714,14 @@ namespace MediaBrowser.Controller.Library /// The path to the virtualfolder. /// The new virtualfolder. public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo); + + /// + /// Re-routes LinkedChildren references from one child to another. + /// Used when video versions change to maintain playlist/BoxSet integrity. + /// + /// The child ID to re-route from. + /// The child ID to re-route to. + /// Number of references updated. + int RerouteLinkedChildReferences(Guid fromChildId, Guid toChildId); } } diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index f7ed39730e..504adff86c 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -210,4 +210,21 @@ public interface IItemRepository /// The user ID for access filtering. /// Dictionary mapping parent ID to child count. Dictionary GetChildCountBatch(IReadOnlyList parentIds, Guid? userId); + + /// + /// Gets parent IDs (Playlists/BoxSets) that reference the specified child with LinkedChildType.Manual. + /// + /// The child item ID. + /// List of parent IDs that reference the child. + IReadOnlyList GetManualLinkedParentIds(Guid childId); + + /// + /// Updates LinkedChildren references from one child to another, preserving SortOrder. + /// Handles duplicates: if parent already references toChildId, removes the old reference instead. + /// Used when video versions change to maintain collection integrity. + /// + /// The child ID to re-route from. + /// The child ID to re-route to. + /// Number of references updated. + int RerouteLinkedChildren(Guid fromChildId, Guid toChildId); } -- cgit v1.2.3 From 2789532aa88ccc899ff8497537642e1d78b31ef5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 31 Jan 2026 19:19:26 +0100 Subject: Optimize Validator and Filter Performance --- .../Library/LibraryManager.cs | 6 + .../Library/Validators/ArtistsValidator.cs | 12 +- .../Library/Validators/GenresValidator.cs | 17 +- .../Library/Validators/StudiosValidator.cs | 17 +- Jellyfin.Api/Controllers/FilterController.cs | 44 +- .../Item/BaseItemRepository.cs | 139 +- .../Item/FolderAwareFilterExtensions.cs | 47 +- .../Item/PeopleRepository.cs | 3 +- .../Users/UserManager.cs | 4 +- MediaBrowser.Controller/Library/ILibraryManager.cs | 7 + .../Persistence/IItemRepository.cs | 7 + .../ModelConfiguration/BaseItemConfiguration.cs | 1 + .../PeopleBaseItemMapConfiguration.cs | 1 + .../ModelConfiguration/UserDataConfiguration.cs | 2 + ...20260130232147_AddBaseItemNameIndex.Designer.cs | 1801 ++++++++++++++++++++ .../20260130232147_AddBaseItemNameIndex.cs | 45 + .../Migrations/JellyfinDbModelSnapshot.cs | 6 + 17 files changed, 2072 insertions(+), 87 deletions(-) create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs (limited to 'MediaBrowser.Controller/Persistence') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 830c918541..15705dee96 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3495,5 +3495,11 @@ namespace Emby.Server.Implementations.Library { return _itemRepository.RerouteLinkedChildren(fromChildId, toChildId); } + + /// + public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query) + { + return _itemRepository.GetQueryFiltersLegacy(query); + } } } diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index 8de3bff7ab..b196b1e038 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -55,6 +55,8 @@ public class ArtistsValidator IncludeItemTypes = [BaseItemKind.MusicArtist] }).ToHashSet(); + var existingArtists = _libraryManager.GetArtists(names); + var numComplete = 0; var count = names.Count; var refreshed = 0; @@ -63,7 +65,15 @@ public class ArtistsValidator { try { - var item = _libraryManager.GetArtist(name); + MusicArtist? item = null; + if (existingArtists.TryGetValue(name, out var artists) && artists.Length > 0) + { + item = artists.OrderBy(i => i.IsAccessedByName ? 1 : 0).First(); + } + + // Fall back to GetArtist if not found (creates new item if needed) + item ??= _libraryManager.GetArtist(name); + if (!existingArtistIds.Contains(item.Id)) { await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs index 09ede8bb8d..badd6f1183 100644 --- a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs @@ -54,6 +54,13 @@ public class GenresValidator IncludeItemTypes = [BaseItemKind.Genre] }).ToHashSet(); + var existingGenres = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Genre] + }).Cast() + .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var numComplete = 0; var count = names.Count; var refreshed = 0; @@ -62,7 +69,15 @@ public class GenresValidator { try { - var item = _libraryManager.GetGenre(name); + Genre? item = null; + if (existingGenres.TryGetValue(name, out var existingGenre)) + { + item = existingGenre; + } + + // Fall back to GetGenre if not found (creates new item if needed) + item ??= _libraryManager.GetGenre(name); + if (!existingGenreIds.Contains(item.Id)) { await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index dd124feece..9fb6869171 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -55,6 +55,13 @@ public class StudiosValidator IncludeItemTypes = [BaseItemKind.Studio] }).ToHashSet(); + var existingStudios = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Studio] + }).Cast() + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var numComplete = 0; var count = names.Count; var refreshed = 0; @@ -63,7 +70,15 @@ public class StudiosValidator { try { - var item = _libraryManager.GetStudio(name); + Studio? item = null; + if (existingStudios.TryGetValue(name, out var existingStudio)) + { + item = existingStudio; + } + + // Fall back to GetStudio if not found (creates new item if needed) + item ??= _libraryManager.GetStudio(name); + if (!existingStudioIds.Contains(item.Id)) { await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 3f9aa93a64..5ad127ad8c 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -68,53 +68,27 @@ public class FilterController : BaseJellyfinApiController item = _libraryManager.GetParentItem(parentId, user?.Id); } - var query = new InternalItemsQuery + if (item is not Folder folder) + { + return new QueryFiltersLegacy(); + } + + var query = new InternalItemsQuery(user) { - User = user, MediaTypes = mediaTypes, IncludeItemTypes = includeItemTypes, Recursive = true, EnableTotalRecordCount = false, + AncestorIds = [folder.Id], DtoOptions = new DtoOptions { - Fields = new[] { ItemFields.Genres, ItemFields.Tags }, + Fields = [], EnableImages = false, EnableUserData = false } }; - if (item is not Folder folder) - { - return new QueryFiltersLegacy(); - } - - var itemList = folder.GetItemList(query); - return new QueryFiltersLegacy - { - Years = itemList.Select(i => i.ProductionYear ?? -1) - .Where(i => i > 0) - .Distinct() - .Order() - .ToArray(), - - Genres = itemList.SelectMany(i => i.Genres) - .DistinctNames() - .Order() - .ToArray(), - - Tags = itemList - .SelectMany(i => i.Tags) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray(), - - OfficialRatings = itemList - .Select(i => i.OfficialRating) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray() - }; + return _libraryManager.GetQueryFiltersLegacy(query); } /// diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 22178e57f7..55ef5972d4 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -993,7 +993,7 @@ public sealed class BaseItemRepository dbQuery = dbQuery.Include(e => e.Extras); } - return dbQuery; + return dbQuery.AsSingleQuery(); } private IQueryable ApplyQueryPaging(IQueryable dbQuery, InternalItemsQuery filter) @@ -1046,6 +1046,62 @@ public sealed class BaseItemRepository return dbQuery.Count(); } + /// + public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); + + // Get matching item IDs as a subquery (not materialized) + var matchingItemIds = baseQuery.Select(e => e.Id); + + // Query distinct years directly from the database + var years = baseQuery + .Where(e => e.ProductionYear != null && e.ProductionYear > 0) + .Select(e => e.ProductionYear!.Value) + .Distinct() + .OrderBy(y => y) + .ToArray(); + + // Query distinct official ratings directly from the database + var officialRatings = baseQuery + .Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty) + .Select(e => e.OfficialRating!) + .Distinct() + .OrderBy(r => r) + .ToArray(); + + // Tags via ItemValuesMap JOIN - uses subquery for matching items + var tags = context.ItemValuesMap + .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags) + .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) + .Select(ivm => ivm.ItemValue.CleanValue) + .Distinct() + .OrderBy(t => t) + .ToArray(); + + // Genres via ItemValuesMap JOIN - uses subquery for matching items + var genres = context.ItemValuesMap + .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre) + .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) + .Select(ivm => ivm.ItemValue.CleanValue) + .Distinct() + .OrderBy(g => g) + .ToArray(); + + return new QueryFiltersLegacy + { + Years = years, + OfficialRatings = officialRatings, + Tags = tags, + Genres = genres + }; + } + /// public ItemCounts GetItemCounts(InternalItemsQuery filter) { @@ -1229,8 +1285,6 @@ public sealed class BaseItemRepository } } - context.SaveChanges(); - var itemValueMaps = tuples .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) .ToArray(); @@ -1255,7 +1309,6 @@ public sealed class BaseItemRepository Value = f.Value }).ToArray(); context.ItemValues.AddRange(missingItemValues); - context.SaveChanges(); var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); var valueMap = itemValueMaps @@ -1291,8 +1344,6 @@ public sealed class BaseItemRepository context.ItemValuesMap.RemoveRange(itemMappedValues); } - context.SaveChanges(); - var itemsWithAncestors = tuples .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null) .Select(t => t.Item.Id) @@ -1619,7 +1670,8 @@ public sealed class BaseItemRepository .Include(e => e.LockedFields) .Include(e => e.UserData) .Include(e => e.Images) - .Include(e => e.LinkedChildEntities); + .Include(e => e.LinkedChildEntities) + .AsSingleQuery(); var item = dbQuery.FirstOrDefault(e => e.Id == id); if (item is null) @@ -2780,7 +2832,7 @@ public sealed class BaseItemRepository if (filter.TrailerTypes.Length > 0) { var trailerTypes = filter.TrailerTypes.Select(e => (int)e).ToArray(); - baseQuery = baseQuery.Where(e => trailerTypes.Any(f => e.TrailerTypes!.Any(w => w.Id == f))); + baseQuery = baseQuery.Where(e => e.TrailerTypes!.Any(w => trailerTypes.Contains(w.Id))); } if (filter.IsAiring.HasValue) @@ -2896,29 +2948,31 @@ public sealed class BaseItemRepository if (filter.IsFavoriteOrLiked.HasValue) { + var favoriteItemIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) + .Select(ud => ud.ItemId); if (filter.IsFavoriteOrLiked.Value) { - baseQuery = baseQuery - .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite)); + baseQuery = baseQuery.Where(e => favoriteItemIds.Contains(e.Id)); } else { - baseQuery = baseQuery - .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite)); + baseQuery = baseQuery.Where(e => !favoriteItemIds.Contains(e.Id)); } } if (filter.IsFavorite.HasValue) { + var favoriteItemIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) + .Select(ud => ud.ItemId); if (filter.IsFavorite.Value) { - baseQuery = baseQuery - .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite)); + baseQuery = baseQuery.Where(e => favoriteItemIds.Contains(e.Id)); } else { - baseQuery = baseQuery - .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.IsFavorite)); + baseQuery = baseQuery.Where(e => !favoriteItemIds.Contains(e.Id)); } } @@ -2927,44 +2981,56 @@ public sealed class BaseItemRepository // We should probably figure this out for all folders, but for right now, this is the only place where we need it if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series) { - // Use subquery to find series with played episodes - stays in SQL instead of materializing to HashSet - var playedSeriesKeysSubquery = context.BaseItems - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesPresentationUniqueKey != null) - .Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Played)) - .Select(e => e.SeriesPresentationUniqueKey!); + // Get played series IDs by joining episodes to UserData via SeriesId (Guid foreign key). + // Don't filter episodes by TopParentIds here - the series will be filtered by baseQuery anyway. + // This allows the materialized list to be reused across library-scoped queries. + var playedSeriesIdList = context.BaseItems + .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) + .Join( + context.UserData.Where(ud => ud.UserId == filter.User!.Id && ud.Played), + episode => episode.Id, + ud => ud.ItemId, + (episode, ud) => episode.SeriesId!.Value) + .Distinct() + .ToList(); if (filter.IsPlayed.Value) { - baseQuery = baseQuery.Where(e => playedSeriesKeysSubquery.Contains(e.PresentationUniqueKey!)); + baseQuery = baseQuery.Where(s => playedSeriesIdList.Contains(s.Id)); } else { - baseQuery = baseQuery.Where(e => !playedSeriesKeysSubquery.Contains(e.PresentationUniqueKey!)); + baseQuery = baseQuery.Where(s => !playedSeriesIdList.Contains(s.Id)); } } - else if (filter.IsPlayed.Value) - { - baseQuery = baseQuery - .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played)); - } else { - baseQuery = baseQuery - .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played)); + var playedItemIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.Played) + .Select(ud => ud.ItemId); + if (filter.IsPlayed.Value) + { + baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id)); + } + else + { + baseQuery = baseQuery.Where(e => !playedItemIds.Contains(e.Id)); + } } } if (filter.IsResumable.HasValue) { + var resumableItemIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0) + .Select(ud => ud.ItemId); if (filter.IsResumable.Value) { - baseQuery = baseQuery - .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.PlaybackPositionTicks > 0)); + baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id)); } else { - baseQuery = baseQuery - .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.PlaybackPositionTicks > 0)); + baseQuery = baseQuery.Where(e => !resumableItemIds.Contains(e.Id)); } } @@ -3681,9 +3747,12 @@ public sealed class BaseItemRepository private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable query, Guid userId) { + // GroupBy with a constant key aggregates all rows into a single group for server-side counting. + // OrderBy is required before FirstOrDefault to avoid EF Core warnings about unpredictable results. var result = query .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) - .GroupBy(_ => 1) // Hack to aggregate over entire set + .GroupBy(_ => 1) + .OrderBy(g => g.Key) .Select(g => new { Total = g.Count(), diff --git a/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs b/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs index d7b2567f37..888dacd16b 100644 --- a/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs +++ b/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs @@ -26,13 +26,25 @@ internal static class FolderAwareFilterExtensions JellyfinDbContext context, Expression> condition) { - // Use correlated Any() subqueries instead of UNION + Contains for better index utilization - var matchingIds = context.BaseItems.Where(condition).Select(b => b.Id); + // Get IDs of items that directly match the condition + var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id); - return query.Where(e => - matchingIds.Contains(e.Id) - || context.AncestorIds.Any(a => a.ParentItemId == e.Id && matchingIds.Contains(a.ItemId)) - || context.LinkedChildren.Any(lc => lc.ParentId == e.Id && matchingIds.Contains(lc.ChildId))); + // Get parent IDs where a descendant (via AncestorIds) matches + var ancestorMatchIds = context.AncestorIds + .Where(a => directMatchIds.Contains(a.ItemId)) + .Select(a => a.ParentItemId); + + // Get parent IDs where a linked child matches + var linkedMatchIds = context.LinkedChildren + .Where(lc => directMatchIds.Contains(lc.ChildId)) + .Select(lc => lc.ParentId); + + var allMatchingIds = directMatchIds + .Concat(ancestorMatchIds) + .Concat(linkedMatchIds) + .Distinct(); + + return query.Where(e => allMatchingIds.Contains(e.Id)); } /// @@ -48,11 +60,24 @@ internal static class FolderAwareFilterExtensions JellyfinDbContext context, Expression> condition) { - var matchingIds = context.BaseItems.Where(condition).Select(b => b.Id); + // Get IDs of items that directly match the condition + var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id); + + // Get parent IDs where a descendant (via AncestorIds) matches + var ancestorMatchIds = context.AncestorIds + .Where(a => directMatchIds.Contains(a.ItemId)) + .Select(a => a.ParentItemId); + + // Get parent IDs where a linked child matches + var linkedMatchIds = context.LinkedChildren + .Where(lc => directMatchIds.Contains(lc.ChildId)) + .Select(lc => lc.ParentId); + + var allMatchingIds = directMatchIds + .Concat(ancestorMatchIds) + .Concat(linkedMatchIds) + .Distinct(); - return query.Where(e => - !matchingIds.Contains(e.Id) - && !context.AncestorIds.Any(a => a.ParentItemId == e.Id && matchingIds.Contains(a.ItemId)) - && !context.LinkedChildren.Any(lc => lc.ParentId == e.Id && matchingIds.Contains(lc.ChildId))); + return query.Where(e => !allMatchingIds.Contains(e.Id)); } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 355ed64797..022b452e85 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -62,10 +62,9 @@ public class PeopleRepository(IDbContextFactory dbProvider, I using var context = _dbProvider.CreateDbContext(); var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct(); - // dbQuery = dbQuery.OrderBy(e => e.ListOrder); if (filter.Limit > 0) { - dbQuery = dbQuery.Take(filter.Limit); + dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit); } return dbQuery.ToArray(); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 501cb4fbe8..7292e9c7a9 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -93,7 +93,7 @@ namespace Jellyfin.Server.Implementations.Users _users = new ConcurrentDictionary(); using var dbContext = _dbProvider.CreateDbContext(); foreach (var user in dbContext.Users - .AsSplitQuery() + .AsSingleQuery() .Include(user => user.Permissions) .Include(user => user.Preferences) .Include(user => user.AccessSchedules) @@ -607,6 +607,7 @@ namespace Jellyfin.Server.Implementations.Users .Include(u => u.Preferences) .Include(u => u.AccessSchedules) .Include(u => u.ProfileImage) + .AsSingleQuery() .FirstOrDefault(u => u.Id.Equals(userId)) ?? throw new ArgumentException("No user exists with given Id!"); @@ -651,6 +652,7 @@ namespace Jellyfin.Server.Implementations.Users .Include(u => u.Preferences) .Include(u => u.AccessSchedules) .Include(u => u.ProfileImage) + .AsSingleQuery() .FirstOrDefault(u => u.Id.Equals(userId)) ?? throw new ArgumentException("No user exists with given Id!"); diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 48859de04b..adb590ddbe 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -723,5 +723,12 @@ namespace MediaBrowser.Controller.Library /// The child ID to re-route to. /// Number of references updated. int RerouteLinkedChildReferences(Guid fromChildId, Guid toChildId); + + /// + /// Gets legacy query filters for filtering UI. + /// + /// The query filter. + /// Aggregated filter values. + QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); } } diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 504adff86c..a063debbb5 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -218,6 +218,13 @@ public interface IItemRepository /// List of parent IDs that reference the child. IReadOnlyList GetManualLinkedParentIds(Guid childId); + /// + /// Gets legacy query filters (Years, Genres, Tags, OfficialRatings) aggregated directly from the database. + /// + /// The query filter. + /// Aggregated filter values. + QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter); + /// /// Updates LinkedChildren references from one child to another, preserving SortOrder. /// Handles duplicates: if parent already references toChildId, removes the old reference instead. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs index 4ec1b972dd..965da7ffd2 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs @@ -36,6 +36,7 @@ public class BaseItemConfiguration : IEntityTypeConfiguration builder.HasIndex(e => e.Path); builder.HasIndex(e => e.ParentId); builder.HasIndex(e => e.OwnerId); + builder.HasIndex(e => e.Name); builder.HasIndex(e => e.ExtraType); builder.HasIndex(e => new { e.ExtraType, e.OwnerId }); builder.HasIndex(e => e.PresentationUniqueKey); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs index f7694aeda0..32ede86c96 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs @@ -15,6 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration new { e.ItemId, e.PeopleId, e.Role }); builder.HasIndex(e => new { e.ItemId, e.SortOrder }); builder.HasIndex(e => new { e.ItemId, e.ListOrder }); + builder.HasIndex(e => e.PeopleId); builder.HasOne(e => e.Item); builder.HasOne(e => e.People); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs index 223b2f8784..42848c6c4e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs @@ -18,6 +18,8 @@ public class UserDataConfiguration : IEntityTypeConfiguration builder.HasIndex(d => new { d.ItemId, d.UserId, d.IsFavorite }); builder.HasIndex(d => new { d.ItemId, d.UserId, d.LastPlayedDate }); builder.HasIndex(d => new { d.UserId, d.ItemId, d.LastPlayedDate }); + builder.HasIndex(d => new { d.UserId, d.Played, d.ItemId }); + builder.HasIndex(d => new { d.UserId, d.IsFavorite, d.ItemId }); builder.HasOne(e => e.Item).WithMany(e => e.UserData); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs new file mode 100644 index 0000000000..1b396a707c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs @@ -0,0 +1,1801 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260130232147_AddBaseItemNameIndex")] + partial class AddBaseItemNameIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ExtraType"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ChildId") + .HasColumnType("TEXT"); + + b.Property("ChildType") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "ChildId"); + + b.HasIndex("ChildId"); + + b.HasIndex("ParentId"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.HasIndex("ParentId", "SortOrder"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RetentionDate") + .HasColumnType("TEXT"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs new file mode 100644 index 0000000000..da57c71662 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// + public partial class AddBaseItemNameIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_IsFavorite_ItemId", + table: "UserData", + columns: new[] { "UserId", "IsFavorite", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId_Played_ItemId", + table: "UserData", + columns: new[] { "UserId", "Played", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Name", + table: "BaseItems", + column: "Name"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_IsFavorite_ItemId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_UserData_UserId_Played_ItemId", + table: "UserData"); + + migrationBuilder.DropIndex( + name: "IX_BaseItems_Name", + table: "BaseItems"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs index c7d884f15e..865091530b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -362,6 +362,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("ExtraType"); + b.HasIndex("Name"); + b.HasIndex("OwnerId"); b.HasIndex("ParentId"); @@ -1446,8 +1448,12 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("ItemId", "UserId", "Played"); + b.HasIndex("UserId", "IsFavorite", "ItemId"); + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + b.HasIndex("UserId", "Played", "ItemId"); + b.ToTable("UserData"); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); -- cgit v1.2.3 From a0346fe5b70a434860f973086be176ecc2018a52 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 5 Feb 2026 00:17:44 +0100 Subject: Fix multiple version handling --- .../Library/LibraryManager.cs | 74 +++++++++++++++++++++- .../Item/BaseItemRepository.cs | 54 +++++++++++++++- .../Migrations/Routines/MigrateLinkedChildren.cs | 58 +++++++++++++++++ MediaBrowser.Controller/Entities/BaseItem.cs | 11 +++- MediaBrowser.Controller/Entities/Movies/Movie.cs | 19 ++++++ MediaBrowser.Controller/Entities/Video.cs | 33 +++++++++- MediaBrowser.Controller/Library/ILibraryManager.cs | 9 +++ .../Persistence/IItemRepository.cs | 10 +++ 8 files changed, 260 insertions(+), 8 deletions(-) (limited to 'MediaBrowser.Controller/Persistence') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 15705dee96..38aec6d491 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1986,6 +1986,12 @@ namespace Emby.Server.Implementations.Library return []; } + /// + public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType) + { + _itemRepository.UpsertLinkedChild(parentId, childId, childType); + } + /// public IEnumerable Sort(IEnumerable items, User? user, IEnumerable sortBy, SortOrder sortOrder) { @@ -2090,9 +2096,40 @@ namespace Emby.Server.Implementations.Library /// public void CreateItems(IReadOnlyList items, BaseItem? parent, CancellationToken cancellationToken) { - _itemRepository.SaveItems(items, cancellationToken); - + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List(items); foreach (var item in items) + { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + var altVideo = ResolvePath(_fileSystem.GetFileSystemInfo(path)) as Video; + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + allItems.Add(altVideo); + } + } + } + } + } + + _itemRepository.SaveItems(allItems, cancellationToken); + + foreach (var item in allItems) { RegisterItem(item); } @@ -2258,7 +2295,38 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } - _itemRepository.SaveItems(items, cancellationToken); + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List(items); + foreach (var item in items) + { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + var altVideo = ResolvePath(_fileSystem.GetFileSystemInfo(path)) as Video; + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + allItems.Add(altVideo); + } + } + } + } + } + + _itemRepository.SaveItems(allItems, cancellationToken); if (parent is Folder folder) { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index b154592e6e..76769c33e7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -1599,10 +1599,35 @@ public sealed class BaseItemRepository } } - // Remove orphaned alternate version links + // Remove orphaned alternate version links and their items if (existingLinkedChildren.Count > 0) { + // Get the child IDs of LocalAlternateVersions that are being removed + // These items should be deleted as they are owned by this video + var orphanedLocalVersionIds = existingLinkedChildren + .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion) + .Select(e => e.ChildId) + .ToList(); + context.LinkedChildren.RemoveRange(existingLinkedChildren); + + // Delete the orphaned LocalAlternateVersion items themselves + if (orphanedLocalVersionIds.Count > 0) + { + var orphanedItems = context.BaseItems + .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id) + .ToList(); + + if (orphanedItems.Count > 0) + { + _logger.LogInformation( + "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})", + orphanedItems.Count, + video.Name, + video.Id); + context.BaseItems.RemoveRange(orphanedItems); + } + } } } } @@ -3942,4 +3967,31 @@ public sealed class BaseItemRepository return updated; } + + /// + public void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType) + { + using var context = _dbProvider.CreateDbContext(); + + var dbChildType = (DbLinkedChildType)childType; + var existingLink = context.LinkedChildren + .FirstOrDefault(lc => lc.ParentId == parentId && lc.ChildId == childId); + + if (existingLink is null) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = dbChildType, + SortOrder = null + }); + } + else + { + existingLink.ChildType = dbChildType; + } + + context.SaveChanges(); + } } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs index 15108a07b4..d08d1a4efd 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs @@ -182,6 +182,18 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine if (toInsert.Count > 0) { + // Deduplicate by composite key (ParentId, ChildId) + // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other + toInsert = toInsert + .OrderBy(lc => lc.ChildType switch + { + LinkedChildType.LocalAlternateVersion => 0, + LinkedChildType.LinkedAlternateVersion => 1, + _ => 2 + }) + .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) + .ToList(); + var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); var existingChildIds = context.BaseItems .Where(b => childIds.Contains(b.Id)) @@ -207,9 +219,55 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); + UpdateAlternateVersionTypes(context); CleanupOrphanedLinkedChildren(context); } + private void UpdateAlternateVersionTypes(JellyfinDbContext context) + { + _logger.LogInformation("Updating alternate version item types to match their parent's type..."); + + // Find all LocalAlternateVersion relationships where the child is a generic Video + // but the parent is a more specific type (like Movie) + var genericVideoType = "MediaBrowser.Controller.Entities.Video"; + + var alternateVersionsToUpdate = context.LinkedChildren + .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion) + .Join( + context.BaseItems, + lc => lc.ParentId, + parent => parent.Id, + (lc, parent) => new { lc.ChildId, ParentType = parent.Type }) + .Join( + context.BaseItems, + x => x.ChildId, + child => child.Id, + (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type, Child = child }) + .Where(x => x.ChildType == genericVideoType && x.ParentType != genericVideoType) + .ToList(); + + if (alternateVersionsToUpdate.Count == 0) + { + _logger.LogInformation("No alternate version items need type updates."); + return; + } + + _logger.LogInformation("Found {Count} alternate version items to update.", alternateVersionsToUpdate.Count); + + foreach (var item in alternateVersionsToUpdate) + { + item.Child.Type = item.ParentType; + _logger.LogDebug( + "Updating item {ChildId} type from {OldType} to {NewType}", + item.ChildId, + genericVideoType, + item.ParentType); + } + + context.SaveChanges(); + _logger.LogInformation("Successfully updated {Count} alternate version item types.", alternateVersionsToUpdate.Count); + } + private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) { _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records..."); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index f1c1555842..3f04b1ffae 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1430,10 +1430,15 @@ namespace MediaBrowser.Controller.Entities }); foreach (var removedExtra in removedExtras) { - LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + // Only delete items that are actual extras (have ExtraType set) + // Items with OwnerId but no ExtraType might be alternate versions, not extras + if (removedExtra.ExtraType.HasValue) { - DeleteFileLocation = false - }); + LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + { + DeleteFileLocation = false + }); + } } } diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 797f44e2d5..5ab149a49d 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -9,8 +9,10 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.Movies { @@ -91,6 +93,20 @@ namespace MediaBrowser.Controller.Entities.Movies }; var id = LibraryManager.GetNewItemId(path, typeof(Movie)); + + // Check if the file still exists + if (!FileSystem.FileExists(path)) + { + // File was removed - clean up any orphaned database entry + if (LibraryManager.GetItemById(id) is Movie orphanedMovie && orphanedMovie.OwnerId.Equals(Id)) + { + Logger.LogInformation("Alternate version file no longer exists, removing orphaned item: {Path}", path); + LibraryManager.DeleteItem(orphanedMovie, new DeleteOptions { DeleteFileLocation = false }); + } + + return; + } + if (LibraryManager.GetItemById(id) is not Movie movie) { movie = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Movie; @@ -109,6 +125,9 @@ namespace MediaBrowser.Controller.Entities.Movies } await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + + // Create LinkedChild entry for this local alternate version + LibraryManager.UpsertLinkedChild(Id, movie.Id, LinkedChildType.LocalAlternateVersion); } /// diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 1ddc193359..21aa50b49d 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -19,6 +19,7 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities { @@ -428,6 +429,17 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + // Clean up LocalAlternateVersions - remove paths that no longer exist + if (LocalAlternateVersions.Length > 0) + { + var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray(); + if (validPaths.Length != LocalAlternateVersions.Length) + { + LocalAlternateVersions = validPaths; + hasChanges = true; + } + } + if (IsStacked) { var tasks = AdditionalParts @@ -467,7 +479,21 @@ namespace MediaBrowser.Controller.Entities SearchResult = null }; - var id = LibraryManager.GetNewItemId(path, typeof(Video)); + var id = LibraryManager.GetNewItemId(path, GetType()); + + // Check if the file still exists + if (!FileSystem.FileExists(path)) + { + // File was removed - clean up any orphaned database entry + if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id)) + { + Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path); + LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false }); + } + + return; + } + if (LibraryManager.GetItemById(id) is not Video video) { video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video; @@ -486,6 +512,11 @@ namespace MediaBrowser.Controller.Entities } await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + + // Create LinkedChild entry for this local alternate version + // This ensures the relationship exists in the database even if the alternate version + // was created after the primary video was first saved + LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion); } private void RefreshLinkedAlternateVersions() diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index adb590ddbe..9b46dec3fe 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -20,6 +20,7 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Genre = MediaBrowser.Controller.Entities.Genre; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; using Person = MediaBrowser.Controller.Entities.Person; namespace MediaBrowser.Controller.Library @@ -229,6 +230,14 @@ namespace MediaBrowser.Controller.Library /// Enumerable of linked Video items. IEnumerable