diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 733a734ef3..7ca5a471cd 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -381,12 +381,6 @@ public sealed class BaseItemRepository
.Select(g => g.GroupKey);
}
- var topGroupKeysList = topGroupKeys.ToList();
- if (topGroupKeysList.Count == 0)
- {
- return [];
- }
-
var itemsQuery = collectionType switch
{
CollectionType.movies => baseQuery.Where(e => e.PresentationUniqueKey != null && topGroupKeys.Contains(e.PresentationUniqueKey)),
@@ -396,14 +390,12 @@ public sealed class BaseItemRepository
itemsQuery = itemsQuery.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id);
itemsQuery = ApplyNavigations(itemsQuery, filter).AsSingleQuery();
- var latestItems = itemsQuery
+ return itemsQuery
+ .AsEnumerable()
.GroupBy(groupKeySelector)
.Select(g => g.First())
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
- .ToList();
-
- return latestItems
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
.Where(dto => dto is not null)
.ToArray()!;
@@ -413,14 +405,32 @@ public sealed class BaseItemRepository
/// Gets the latest TV show items with smart Season/Series container selection.
///
///
- /// If multiple episodes were recently added to a single season, returns the Season.
- /// If episodes span multiple seasons, returns the Series.
- /// If only a single episode was added, returns the Episode.
+ ///
+ /// This method implements intelligent container selection for TV shows in the "Latest" section.
+ /// Instead of always showing individual episodes, it analyzes recent additions and may return
+ /// a Season or Series container when multiple related episodes were recently added.
+ ///
+ ///
+ /// The selection logic is:
+ ///
+ /// - If recent episodes span multiple seasons → return the Series
+ /// - If multiple recent episodes are from one season AND the series has multiple seasons → return the Season
+ /// - If multiple recent episodes are from one season AND the series has only one season → return the Series
+ /// - Otherwise → return the most recent Episode
+ ///
+ ///
///
+ /// The database context.
+ /// The base query with filters already applied.
+ /// The query filter options.
+ /// Maximum number of items to return.
+ /// A list of BaseItemDto representing the latest TV content.
private IReadOnlyList GetLatestTvShowItems(JellyfinDbContext context, IQueryable baseQuery, InternalItemsQuery filter, int limit)
{
+ // Episodes added within this window are considered "recently added together"
const double RecentAdditionWindowHours = 24.0;
+ // Step 1: Find the top N series with recently added content, ordered by most recent addition
var topSeriesNames = baseQuery
.Where(e => e.SeriesName != null)
.GroupBy(e => e.SeriesName)
@@ -430,12 +440,7 @@ public sealed class BaseItemRepository
.Select(g => g.SeriesName)
.ToList();
- if (topSeriesNames.Count == 0)
- {
- return [];
- }
-
- // Get all episodes from identified series with navigations
+ // Step 2: Fetch all episodes from the identified series (needed for analysis)
var allEpisodes = ApplyNavigations(
baseQuery
.Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName))
@@ -445,20 +450,25 @@ public sealed class BaseItemRepository
.AsSingleQuery()
.ToList();
+ // Collect all season/series IDs we'll need to look up for count information
var allSeasonIds = new HashSet();
var allSeriesIds = new HashSet();
+
+ // Analysis data for each series: which episodes were recently added and to which seasons
var analysisData = new List<(
List RecentEpisodes,
List SeasonIds,
DateTime MaxDate,
BaseItemEntity MostRecentEpisode)>();
+ // Step 3: Analyze each series to identify recent additions within the time window
foreach (var group in allEpisodes.GroupBy(e => e.SeriesName))
{
var episodes = group.ToList();
var mostRecentDate = episodes[0].DateCreated ?? DateTime.MinValue;
var recentCutoff = mostRecentDate.AddHours(-RecentAdditionWindowHours);
+ // Find episodes added within the recent window
var recentEpisodes = new List();
var seasonIdSet = new HashSet();
@@ -477,6 +487,7 @@ public sealed class BaseItemRepository
var seasonIds = seasonIdSet.ToList();
analysisData.Add((recentEpisodes, seasonIds, mostRecentDate, episodes[0]));
+ // Track all unique season/series IDs for batch lookups
foreach (var sid in seasonIds)
{
allSeasonIds.Add(sid);
@@ -488,6 +499,8 @@ public sealed class BaseItemRepository
}
}
+ // Step 4: Batch fetch counts - episodes per season and seasons per series
+ // These counts help determine whether to show Season or Series as the container
var episodeType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var seasonType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Season];
var seasonEpisodeCounts = allSeasonIds.Count > 0
@@ -508,8 +521,10 @@ public sealed class BaseItemRepository
.ToDictionary(x => x.SeriesId, x => x.Count)
: [];
+ // Step 5: Apply the container selection logic for each series
var entitiesToFetch = new HashSet();
var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, BaseItemEntity MostRecentEpisode)>(analysisData.Count);
+
foreach (var (recentEpisodes, seasonIds, maxDate, mostRecentEpisode) in analysisData)
{
Guid? seasonId = null;
@@ -517,6 +532,7 @@ public sealed class BaseItemRepository
if (seasonIds.Count == 1)
{
+ // All recent episodes are from a single season
var sid = seasonIds[0];
var totalEpisodes = seasonEpisodeCounts.GetValueOrDefault(sid, 0);
var episodeSeriesId = recentEpisodes.Count > 0 ? recentEpisodes[0].SeriesId : null;
@@ -524,20 +540,27 @@ public sealed class BaseItemRepository
? seriesSeasonCounts.GetValueOrDefault(episodeSeriesId.Value, 1)
: 1;
+ // Check if multiple episodes were added, or if all episodes in the season were added
var hasMultipleOrAllEpisodes = recentEpisodes.Count > 1 || recentEpisodes.Count == totalEpisodes;
+
if (totalSeasonsInSeries > 1 && hasMultipleOrAllEpisodes)
{
+ // Multi-season series with bulk additions: show the Season
seasonId = sid;
entitiesToFetch.Add(sid);
}
else if (hasMultipleOrAllEpisodes && episodeSeriesId.HasValue)
{
+ // Single-season series with bulk additions: show the Series
seriesId = episodeSeriesId;
entitiesToFetch.Add(episodeSeriesId.Value);
}
+
+ // Otherwise: single episode, will fall through to show the Episode
}
else if (seasonIds.Count > 1 && recentEpisodes.Count > 0 && recentEpisodes[0].SeriesId.HasValue)
{
+ // Recent episodes span multiple seasons: show the Series
seriesId = recentEpisodes[0].SeriesId;
entitiesToFetch.Add(seriesId!.Value);
}
@@ -545,6 +568,7 @@ public sealed class BaseItemRepository
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisode));
}
+ // Step 6: Fetch the Season/Series entities we decided to return
var entities = entitiesToFetch.Count > 0
? ApplyNavigations(
context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
@@ -553,6 +577,7 @@ public sealed class BaseItemRepository
.ToDictionary(e => e.Id)
: [];
+ // Step 7: Build final results, preferring Season > Series > Episode
var results = new List<(BaseItemEntity Entity, DateTime MaxDate)>(seriesResults.Count);
foreach (var (seasonId, seriesId, maxDate, mostRecentEpisode) in seriesResults)
{
@@ -568,6 +593,7 @@ public sealed class BaseItemRepository
continue;
}
+ // Fallback: show the most recent episode
results.Add((mostRecentEpisode, maxDate));
}
@@ -685,40 +711,6 @@ public sealed class BaseItemRepository
lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id);
}
- var allCandidatesWithPlayedStatus = context.BaseItems
- .AsNoTracking()
- .Where(e => e.Type == episodeTypeName)
- .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
- .Where(e => e.ParentIndexNumber != 0)
- .Where(e => !e.IsVirtualItem)
- .GroupJoin(
- context.UserData.AsNoTracking().Where(ud => ud.UserId == userId),
- e => e.Id,
- ud => ud.ItemId,
- (episode, userData) => new
- {
- episode.Id,
- episode.SeriesPresentationUniqueKey,
- episode.ParentIndexNumber,
- EpisodeNumber = episode.IndexNumber,
- IsPlayed = userData.Any(ud => ud.Played)
- })
- .ToList();
-
- var allNextUnwatchedCandidates = allCandidatesWithPlayedStatus
- .Where(c => !c.IsPlayed)
- .Select(c => new { c.Id, c.SeriesPresentationUniqueKey, c.ParentIndexNumber, c.EpisodeNumber })
- .ToList();
-
- List<(Guid Id, string? SeriesKey, int? Season, int? Episode)> allNextPlayedCandidates = new();
- if (includeWatchedForRewatching)
- {
- allNextPlayedCandidates = allCandidatesWithPlayedStatus
- .Where(c => c.IsPlayed)
- .Select(c => (c.Id, c.SeriesPresentationUniqueKey, c.ParentIndexNumber, c.EpisodeNumber))
- .ToList();
- }
-
Dictionary> specialsBySeriesKey = new();
if (includeSpecials)
{
@@ -757,10 +749,34 @@ public sealed class BaseItemRepository
var nextEpisodeIds = new HashSet();
var seriesNextIdMap = new Dictionary();
var seriesNextPlayedIdMap = new Dictionary();
+ var allCandidatesWithPlayedStatus = context.BaseItems
+ .AsNoTracking()
+ .Where(e => e.Type == episodeTypeName)
+ .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
+ .Where(e => e.ParentIndexNumber != 0)
+ .Where(e => !e.IsVirtualItem)
+ .GroupJoin(
+ context.UserData.AsNoTracking().Where(ud => ud.UserId == userId),
+ e => e.Id,
+ ud => ud.ItemId,
+ (episode, userData) => new
+ {
+ episode.Id,
+ episode.SeriesPresentationUniqueKey,
+ episode.ParentIndexNumber,
+ EpisodeNumber = episode.IndexNumber,
+ IsPlayed = userData.Any(ud => ud.Played)
+ })
+ .ToList();
+
+ var allNextPlayedCandidates = allCandidatesWithPlayedStatus
+ .Where(c => includeWatchedForRewatching)
+ .Select(c => new { c.Id, c.SeriesPresentationUniqueKey, c.ParentIndexNumber, c.EpisodeNumber })
+ .ToList();
foreach (var seriesKey in seriesKeys)
{
- var candidates = allNextUnwatchedCandidates
+ var candidates = allNextPlayedCandidates
.Where(c => c.SeriesPresentationUniqueKey == seriesKey);
if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty)
@@ -799,21 +815,21 @@ public sealed class BaseItemRepository
var lastEp = lastByDateEntity.IndexNumber;
var playedCandidates = allNextPlayedCandidates
- .Where(c => c.SeriesKey == seriesKey);
+ .Where(c => c.SeriesPresentationUniqueKey == seriesKey);
if (lastSeason.HasValue && lastEp.HasValue)
{
playedCandidates = playedCandidates.Where(c =>
- c.Season > lastSeason ||
- (c.Season == lastSeason && c.Episode > lastEp));
+ c.ParentIndexNumber > lastSeason ||
+ (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp));
}
var nextPlayedCandidate = playedCandidates
- .OrderBy(c => c.Season)
- .ThenBy(c => c.Episode)
+ .OrderBy(c => c.ParentIndexNumber)
+ .ThenBy(c => c.EpisodeNumber)
.FirstOrDefault();
- if (nextPlayedCandidate.Id != Guid.Empty)
+ if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty)
{
nextEpisodeIds.Add(nextPlayedCandidate.Id);
seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id;
@@ -1100,28 +1116,6 @@ public sealed class BaseItemRepository
.FirstOrDefault(t => t is not null));
}
- ///
- /// Saves image information for an item.
- ///
- /// The item DTO containing image info.
- public void SaveImages(BaseItemDto item)
- {
- ArgumentNullException.ThrowIfNull(item);
-
- var images = item.ImageInfos.Select(e => Map(item.Id, e));
- using var context = _dbProvider.CreateDbContext();
-
- if (!context.BaseItems.Any(bi => bi.Id == item.Id))
- {
- _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
- return;
- }
-
- context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete();
- context.BaseItemImageInfos.AddRange(images);
- context.SaveChanges();
- }
-
///
public void SaveItems(IReadOnlyList items, CancellationToken cancellationToken)
{
@@ -3012,9 +3006,7 @@ public sealed class BaseItemRepository
if (filter.OfficialRatings.Length > 0)
{
var ratings = filter.OfficialRatings;
- Expression> hasOfficialRating = e => ratings.Contains(e.OfficialRating);
-
- baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasOfficialRating);
+ baseQuery = baseQuery.WhereItemOrDescendantMatches(context, e => ratings.Contains(e.OfficialRating));
}
Expression>? minParentalRatingFilter = null;
@@ -3658,8 +3650,7 @@ public sealed class BaseItemRepository
{
var result = query
.Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played))
- .GroupBy(_ => 1)
- .OrderBy(g => g.Key)
+ .GroupBy(_ => 1) // Hack to aggregate over entire set
.Select(g => new
{
Total = g.Count(),