aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs5
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs64
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs210
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs5
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs25
-rw-r--r--Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs39
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs59
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs67
8 files changed, 355 insertions, 119 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 8e917f6951..5a41619390 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -144,11 +144,6 @@ public sealed partial class BaseItemRepository
{
ArgumentNullException.ThrowIfNull(filter);
- if (!filter.Limit.HasValue)
- {
- filter.EnableTotalRecordCount = false;
- }
-
using var context = _dbProvider.CreateDbContext();
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index c7acf72043..c9e08b1b5d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -110,7 +110,7 @@ public sealed partial class BaseItemRepository
PrepareFilterQuery(filter);
// Early exit if collection type is not supported
- if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
+ if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown)
{
return [];
}
@@ -121,30 +121,27 @@ public sealed partial class BaseItemRepository
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
- if (collectionType == CollectionType.tvshows)
+ if (collectionType is CollectionType.tvshows)
{
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
if (collectionType is CollectionType.movies)
{
- // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
- // Build up until limit by streaming through results and deduplicating on the fly.
- var orderedIds = baseQuery
- .Where(e => e.PresentationUniqueKey != null)
- .OrderByDescending(e => e.DateCreated)
- .ThenByDescending(e => e.Id)
- .Select(e => new { e.Id, e.PresentationUniqueKey });
-
- // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
- var firstIds = orderedIds
- .AsEnumerable()
- .DistinctBy(row => row.PresentationUniqueKey)
- .Select(row => row.Id)
+ return GetLatestMovieItems(context, baseQuery, filter, limit);
+ }
+
+ if (collectionType is CollectionType.unknown)
+ {
+ var moviesQuery = baseQuery.Where(e => e.SeriesName == null);
+ var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit);
+ var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit);
+
+ return latestMovies.Concat(latestShows)
+ .OrderByDescending(dto => dto.DateCreated)
+ .ThenByDescending(dto => dto.Id)
.Take(limit ?? int.MaxValue)
.ToList();
-
- return LoadLatestByIds(context, firstIds, filter);
}
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
@@ -226,6 +223,39 @@ public sealed partial class BaseItemRepository
}
/// <summary>
+ /// Gets the latest movies, deduplicated so each movie only appears once.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to pull movies from, with filters already applied.</param>
+ /// <param name="filter">The original query filter, used when loading the final items.</param>
+ /// <param name="limit">How many items to return.</param>
+ /// <returns>The latest movies, newest first.</returns>
+ private IReadOnlyList<BaseItemDto> GetLatestMovieItems(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter,
+ int? limit)
+ {
+ // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
+ // Build up until limit by streaming through results and deduplicating on the fly.
+ var orderedIds = baseQuery
+ .Where(e => e.PresentationUniqueKey != null)
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey });
+
+ // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
+ var firstIds = orderedIds
+ .AsEnumerable()
+ .DistinctBy(row => row.PresentationUniqueKey)
+ .Select(row => row.Id)
+ .Take(limit ?? int.MaxValue)
+ .ToList();
+
+ return LoadLatestByIds(context, firstIds, filter);
+ }
+
+ /// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
/// <remarks>
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 8c0a39fe4c..623c1ea0ab 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -35,6 +35,18 @@ public sealed partial class BaseItemRepository
// instance across several lambdas, and this filter is combined into a tree more than once.
private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
+ // "und" is the language filters' stand-in for a track that declares no language at all.
+ private static string NormalizeLanguage(string language)
+ => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
+
+ // The primary versions whose alternate version satisfies a dimension bound. Anchored on
+ // PrimaryVersionId so the filtered index carries it rather than a scan of every item.
+ private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound)
+ => context.BaseItems
+ .Where(v => v.PrimaryVersionId != null)
+ .Where(bound)
+ .Select(v => v.PrimaryVersionId!.Value);
+
/// <inheritdoc />
public IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
@@ -70,47 +82,86 @@ public sealed partial class BaseItemRepository
include4K = true;
}
- // Non-folders: check own resolution directly (no subquery).
- // Folders (Series, BoxSets): EXISTS check on descendants/linked children.
- // Using navigation properties (a.Item, lc.Child) produces efficient
- // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
+ // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters
+ // on is the best any of the item's versions offers, not just the primary file's. Three sets,
+ // because a bucket is as much about what the version group does not have as what it does, and
+ // because an unprobed primary can still be placed by a version that does carry dimensions.
+ // The filtered PrimaryVersionId index keeps all three to the few items that have versions.
+ var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth);
+ var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth);
+ var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight);
+
+ // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row
+ // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself
+ // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather
+ // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed
+ // exactly as a top-level item is so that the two cannot disagree; the navigation properties
+ // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...).
baseQuery = baseQuery.Where(e =>
- (!e.IsFolder && e.Width > 0
- && ((includeSD && e.Width < HDWidth)
- || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
- || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
+ (!e.IsFolder
+ && ((includeSD
+ && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id))
+ && !versionsHd.Contains(e.Id)
+ && !versions4K.Contains(e.Id))
+ || (includeHD
+ && (e.Width >= HDWidth || versionsHd.Contains(e.Id))
+ && !(e.Width >= UHDWidth || e.Height >= UHDHeight)
+ && !versions4K.Contains(e.Id))
+ || (include4K
+ && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id)))))
|| (e.IsFolder
&& (e.Children!.Any(a =>
- a.Item.Width > 0
- && ((includeSD && a.Item.Width < HDWidth)
- || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
- || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
+ (includeSD
+ && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId))
+ && !versionsHd.Contains(a.ItemId)
+ && !versions4K.Contains(a.ItemId))
+ || (includeHD
+ && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId))
+ && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)
+ && !versions4K.Contains(a.ItemId))
+ || (include4K
+ && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId))))
|| context.LinkedChildren.Any(lc =>
lc.ParentId == e.Id
- && lc.Child!.Width > 0
- && ((includeSD && lc.Child.Width < HDWidth)
- || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
- || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
- }
-
+ && ((includeSD
+ && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId))
+ && !versionsHd.Contains(lc.ChildId)
+ && !versions4K.Contains(lc.ChildId))
+ || (includeHD
+ && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId))
+ && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight)
+ && !versions4K.Contains(lc.ChildId))
+ || (include4K
+ && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId))))))));
+ }
+
+ // Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
if (minWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width >= minWidth);
+ var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth);
+ baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id));
}
if (filter.MinHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight);
+ var minHeight = filter.MinHeight;
+ var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight);
+ baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id));
}
+ // An upper bound inverts that: it is met only if no version breaches it, since the item's
+ // resolution is the best its version group offers.
if (maxWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
+ var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth);
+ baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id));
}
if (filter.MaxHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
+ var maxHeight = filter.MaxHeight;
+ var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight);
+ baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id));
}
if (filter.IsLocked.HasValue)
@@ -356,7 +407,7 @@ public sealed partial class BaseItemRepository
}
else
{
- baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now);
+ baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now);
}
}
@@ -370,14 +421,16 @@ public sealed partial class BaseItemRepository
p => p.Name,
(b, p) => p.Id);
+ var personTypes = filter.PersonTypes;
baseQuery = baseQuery
.Where(e => context.PeopleBaseItemMap
- .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
+ .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.Person))
{
- baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person));
+ var personTypes = filter.PersonTypes;
+ baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId))
@@ -555,7 +608,7 @@ public sealed partial class BaseItemRepository
if (filter.ArtistIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
+ baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
}
if (filter.AlbumArtistIds.Length > 0)
@@ -586,12 +639,12 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeArtistIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
+ baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray());
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds);
}
if (filter.Genres.Count > 0)
@@ -617,7 +670,7 @@ public sealed partial class BaseItemRepository
if (filter.StudioIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray());
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds);
}
if (filter.OfficialRatings.Length > 0)
@@ -759,104 +812,144 @@ public sealed partial class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
- var lang = filter.HasNoAudioTrackWithLanguage;
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
+ var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang);
+ // A track only an alternate version carries still belongs to the item a caller sees, so the
+ // item's own streams alone do not decide this. Same for every stream filter below.
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithAudio.Contains(e.Id))
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
+ var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
+ var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
+ var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (filter.HasSubtitles.HasValue)
{
var hasSubtitles = filter.HasSubtitles.Value;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles());
+ var criteria = new HasSubtitles();
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasSubtitles)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
}
if (filter.SubtitleLanguages.Count > 0)
{
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
- && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
+ && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
if (filter.AudioLanguages.Count > 0)
{
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages);
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
- && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
+ && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithAudio.Contains(e.Id)))
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
}
if (filter.HasChapterImages.HasValue)
{
var hasChapterImages = filter.HasChapterImages.Value;
- var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages());
+ var criteria = new HasChapterImages();
+ var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasChapterImages)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null)
+ || versionsWithChapterImages.Contains(e.Id)))
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)
+ && !versionsWithChapterImages.Contains(e.Id))
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
}
}
@@ -963,17 +1056,6 @@ public sealed partial class BaseItemRepository
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
- if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
- {
- var includeAny = filter.HasAnyProviderIds
- .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
- .ToArray();
- if (includeAny.Length > 0)
- {
- baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f)));
- }
- }
-
if (filter.HasImdbId.HasValue)
{
baseQuery = filter.HasImdbId.Value
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index fd683fb57e..a320ba89d1 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -318,13 +318,14 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var linkedCounts = dbContext.LinkedChildren
- .Where(lc => parentIdsArray.Contains(lc.ParentId))
+ .WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index 827c766449..efff3457a3 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -257,23 +257,19 @@ public class ItemPersistenceService : IItemPersistenceService
using var transaction = context.Database.BeginTransaction();
var ids = tuples.Select(f => f.Item.Id).ToArray();
- var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
+ var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
foreach (var item in tuples)
{
var entity = BaseItemMapper.Map(item.Item, _appHost);
entity.TopParentId = item.TopParent?.Id;
- if (!existingItems.Any(e => e == entity.Id))
+ if (!existingItems.Contains(entity.Id))
{
context.BaseItems.Add(entity);
}
else
{
- context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
-
if (entity.Images is { Count: > 0 })
{
context.BaseItemImageInfos.AddRange(entity.Images);
@@ -314,9 +310,11 @@ public class ItemPersistenceService : IItemPersistenceService
}).ToArray();
context.ItemValues.AddRange(missingItemValues);
- var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
+ var itemValuesStore = existingValues
+ .Concat(missingItemValues)
+ .ToDictionary(e => (e.Type, e.Value));
var valueMap = itemValueMaps
- .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray()))
+ .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray()))
.ToArray();
var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
@@ -401,6 +399,15 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ // Owned rows of updated items are rewritten wholesale; cleared in one statement per table.
+ if (existingItems.Count > 0)
+ {
+ var updatedIds = existingItems.ToArray();
+ context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ }
+
context.SaveChanges();
var folderIds = tuples
@@ -531,7 +538,7 @@ public class ItemPersistenceService : IItemPersistenceService
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
- .Where(e => childIdsToCheck.Contains(e.Id))
+ .WhereOneOrMany(childIdsToCheck, e => e.Id)
.Select(e => e.Id)
.ToHashSet()
: [];
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 5f1d9bf87a..de112d7aa4 100644
--- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
+++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -60,30 +61,50 @@ public class LinkedChildrenService : ILinkedChildrenService
}
/// <inheritdoc/>
+ public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
+ {
+ if (itemIds.Count == 0)
+ {
+ return new HashSet<Guid>();
+ }
+
+ using var dbContext = _dbProvider.CreateDbContext();
+
+ return dbContext.LinkedChildren
+ .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion
+ || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion)
+ .WhereOneOrMany(itemIds, lc => lc.ParentId)
+ .Select(lc => lc.ParentId)
+ .Distinct()
+ .ToHashSet();
+ }
+
+ /// <inheritdoc/>
public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
{
using var dbContext = _dbProvider.CreateDbContext();
- var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray();
+ var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray();
+ var cleanValues = cleanNames.Select(x => x.Clean).ToArray();
+
var artists = dbContext.BaseItems
.AsNoTracking()
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
- .Where(e => lowerNames.Contains(e.Name!.ToLower()))
+ .Where(e => cleanValues.Contains(e.CleanName))
.ToArray();
var lookup = artists
- .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase)
+ .GroupBy(e => e.CleanName!)
.ToDictionary(
g => g.Key,
- g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(),
- StringComparer.OrdinalIgnoreCase);
+ g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
- var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
- foreach (var name in artistNames)
+ var result = new Dictionary<string, MusicArtist[]>(cleanNames.Length);
+ foreach (var (original, clean) in cleanNames)
{
- if (lookup.TryGetValue(name, out var artistArray))
+ if (lookup.TryGetValue(clean, out var artistArray))
{
- result[name] = artistArray;
+ result[original] = artistArray;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 05c8bffd66..aaa363b046 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -236,6 +236,53 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
return result;
}
+ /// <inheritdoc/>
+ public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
+ {
+ using var context = _dbProvider.CreateDbContext();
+ var rows = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Where(m => itemIds.Contains(m.ItemId))
+ .OrderBy(m => m.ListOrder)
+ .Select(m => new
+ {
+ m.ItemId,
+ m.Role,
+ m.SortOrder,
+ m.People.Id,
+ m.People.Name,
+ m.People.PersonType
+ })
+ .ToList();
+
+ var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>();
+ foreach (var group in rows.GroupBy(r => r.ItemId))
+ {
+ var people = new List<PersonInfo>();
+ foreach (var row in group)
+ {
+ var personInfo = new PersonInfo
+ {
+ ItemId = row.ItemId,
+ Id = row.Id,
+ Name = row.Name,
+ Role = row.Role,
+ SortOrder = row.SortOrder
+ };
+ if (Enum.TryParse<PersonKind>(row.PersonType, out var kind))
+ {
+ personInfo.Type = kind;
+ }
+
+ people.Add(personInfo);
+ }
+
+ result[group.Key] = people;
+ }
+
+ return result;
+ }
+
private IEnumerable<PersonInfo> MapCredits(People people)
{
var mappings = people.BaseItems;
@@ -304,7 +351,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (!filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
+ var itemId = filter.ItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(itemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
if (filter.ParentId != null)
@@ -314,7 +365,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (!filter.AppearsInItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
+ var appearsInItemId = filter.AppearsInItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(appearsInItemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 81408d9aa8..fea6084267 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -225,17 +225,8 @@ namespace Jellyfin.Server.Implementations.Users
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
- dbUser.Permissions.Clear();
- foreach (var permission in user.Permissions)
- {
- dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
- }
-
- dbUser.Preferences.Clear();
- foreach (var preference in user.Preferences)
- {
- dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
- }
+ SyncPermissions(dbUser, user.Permissions);
+ SyncPreferences(dbUser, user.Preferences);
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
@@ -269,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users
}
}
+ private static void SyncPermissions(User dbUser, ICollection<Permission> source)
+ {
+ var incoming = new Dictionary<PermissionKind, bool>();
+ foreach (var permission in source)
+ {
+ incoming[permission.Kind] = permission.Value;
+ }
+
+ foreach (var existing in dbUser.Permissions)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ // EF only marks the row modified if the value actually differs, so an update that
+ // touches nothing but the user row - a session activity stamp - writes no children.
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Permissions.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Permissions.Add(new Permission(kind, value));
+ }
+ }
+
+ private static void SyncPreferences(User dbUser, ICollection<Preference> source)
+ {
+ var incoming = new Dictionary<PreferenceKind, string>();
+ foreach (var preference in source)
+ {
+ incoming[preference.Kind] = preference.Value;
+ }
+
+ foreach (var existing in dbUser.Preferences)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Preferences.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Preferences.Add(new Preference(kind, value));
+ }
+ }
+
internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
{
// TODO: Remove after user item data is migrated.