Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 141 additions & 7 deletions Jellyfin.Server.Implementations/Item/ItemCountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,19 +260,21 @@
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();

var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}

/// <inheritdoc/>
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
ArgumentNullException.ThrowIfNull(filter);
using var dbContext = _dbProvider.CreateDbContext();

var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}

Expand All @@ -283,20 +285,33 @@
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();

var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}

private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
{
var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();

var baseQuery = dbContext.BaseItems
.AsNoTracking()
.WhereOneOrMany(descendantIds, b => b.Id)
.Where(DescendantQueryHelper.IsCountableLeaf);

return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
}

/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();

var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
var baseQuery = dbContext.BaseItems
.Where(b => allDescendantIds.Contains(b.Id))
.WhereOneOrMany(allDescendantIds, b => b.Id)
.Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);

Expand Down Expand Up @@ -330,9 +345,17 @@
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);

var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);

var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
{
if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
{
result[parentId] = mergedCount;
continue;
}

var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);

Expand All @@ -342,6 +365,50 @@
return result;
}

private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
.ToArray();

if (mergedGroups.Length == 0)
{
return [];
}

// Only merged folders.
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
var children = dbContext.BaseItems
.AsNoTracking()
.Where(b => b.ParentId.HasValue)
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)

Check warning on line 384 in Jellyfin.Server.Implementations/Item/ItemCountService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AaAmJy5fJG5SZO9WsfPy&open=AaAmJy5fJG5SZO9WsfPy&pullRequest=17693
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })

Check warning on line 385 in Jellyfin.Server.Implementations/Item/ItemCountService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AaAmJy5fJG5SZO9WsfPz&open=AaAmJy5fJG5SZO9WsfPz&pullRequest=17693
.ToArray()
.GroupBy(b => b.ParentId)
.ToDictionary(
g => g.Key,
g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
? b.Id.ToString("N", CultureInfo.InvariantCulture)
: b.PresentationUniqueKey).ToArray());

var result = new Dictionary<Guid, int>();
foreach (var (parentId, members) in mergedGroups)
{
var childKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var member in members)
{
if (children.TryGetValue(member, out var keys))
{
childKeys.UnionWith(keys);
}
}

result[parentId] = childKeys.Count;
}

return result;
}

/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
Expand All @@ -354,10 +421,13 @@
}

using var dbContext = _dbProvider.CreateDbContext();
var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;

// Merged series and seasons are stored as one row per folder-item sharing a presentation key.
var groups = GetPresentationKeyGroups(dbContext, folderIds);
var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();

var leafItems = dbContext.BaseItems
.Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
Expand Down Expand Up @@ -399,7 +469,7 @@
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });

var results = ancestorLeaves
var countsByFolder = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
Expand All @@ -411,9 +481,73 @@
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));

var results = new Dictionary<Guid, (int Played, int Total)>();
foreach (var (folderId, members) in groups)
{
var played = 0;
var total = 0;

// Members of a group are distinct folders, so their leaves cannot overlap.
foreach (var member in members)
{
if (countsByFolder.TryGetValue(member, out var counts))
{
played += counts.Played;
total += counts.Total;
}
}

if (total > 0 || played > 0)
{
results[folderId] = (played, total);
}
}

return results;
}

private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds)
{
var requested = dbContext.BaseItems
.AsNoTracking()
.WhereOneOrMany(folderIds, e => e.Id)
.Select(e => new { e.Id, e.PresentationUniqueKey })
.ToArray();

var keys = requested
.Select(e => e.PresentationUniqueKey)
.Where(key => !string.IsNullOrEmpty(key))
.Distinct(StringComparer.Ordinal)
.ToArray();

// Every item that is not merged carries a key derived from its own id, so in the common case
// each group resolves back to the single folder that was asked for.
var membersByKey = keys.Length == 0
? []
: dbContext.BaseItems
.AsNoTracking()
.Where(e => e.IsFolder)
.WhereOneOrMany(keys, e => e.PresentationUniqueKey!)

Check warning on line 530 in Jellyfin.Server.Implementations/Item/ItemCountService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AaAmJy5fJG5SZO9WsfP0&open=AaAmJy5fJG5SZO9WsfP0&pullRequest=17693
.Select(e => new { e.Id, Key = e.PresentationUniqueKey! })

Check warning on line 531 in Jellyfin.Server.Implementations/Item/ItemCountService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this null-forgiving operator; nullable warnings are disabled here.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AaAmJy5fJG5SZO9WsfP1&open=AaAmJy5fJG5SZO9WsfP1&pullRequest=17693
.ToArray()
.GroupBy(e => e.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);

var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
var groups = new Dictionary<Guid, List<Guid>>();
foreach (var folderId in folderIds)
{
groups[folderId] = keyById.TryGetValue(folderId, out var key)
&& !string.IsNullOrEmpty(key)
&& membersByKey.TryGetValue(key, out var members)
&& members.Count > 0
? members
: [folderId];
}

return groups;
}

private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
Expand Down
Comment thread
Shadowghost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -15,9 +17,9 @@
namespace Jellyfin.Server.Migrations.Routines;

/// <summary>
/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
/// </summary>
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
Expand Down Expand Up @@ -53,6 +55,7 @@ public async Task PerformAsync(CancellationToken cancellationToken)

const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
var newSeriesKeys = new Dictionary<Guid, string>();
var processed = 0;
var updated = 0;

Expand All @@ -68,9 +71,10 @@ public async Task PerformAsync(CancellationToken cancellationToken)
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
}

var oldKey = item.PresentationUniqueKey;
var newKey = item.CreatePresentationUniqueKey();
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
newSeriesKeys[item.Id] = newKey;

if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}
Expand All @@ -82,21 +86,66 @@ await dbContext.BaseItems
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);

// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
// to the series by it. Re-point every child still carrying the old key in a single set-based
// update so they stay attached without waiting for the next scan.
if (!string.IsNullOrEmpty(oldKey))
{
await dbContext.BaseItems
.Where(e => e.SeriesPresentationUniqueKey == oldKey)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
}
// Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
// re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
// by the old key: that key can be shared by every library holding the series, so matching
// on it would drag the other libraries' children along.
await dbContext.BaseItems
.Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);

updated++;
}

var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false);

_logger.LogInformation(
"Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}",
updated,
series.Length,
updatedSeasons,
sw.Elapsed);
}
}

private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken)
{
// A season's own key embeds its series' key, so it goes stale with it.
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Season]
}).OfType<Season>().ToArray();

var updated = 0;

foreach (var season in seasons)
{
cancellationToken.ThrowIfCancellationRequested();

// Without an index number the season keeps the base key, which carries no series key at all.
if (!season.IndexNumber.HasValue
|| !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey))
{
continue;
}

// Mirrors Season.CreatePresentationUniqueKey.
var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture);
if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal))
{
continue;
}

var id = season.Id;
await dbContext.BaseItems
.Where(e => e.Id.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);

updated++;
}

_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
return updated;
}
}
Loading
Loading