Fix Playlist and Boxset query and count perf

This commit is contained in:
Shadowghost
2026-05-04 10:22:13 +02:00
parent 622947e374
commit fa65a392b0
7 changed files with 1949 additions and 19 deletions

View File

@@ -30,11 +30,13 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Sorting;
@@ -1881,6 +1883,25 @@ namespace Emby.Server.Implementations.Library
query.TopParentIds = [Guid.NewGuid()];
}
}
else if (parents.Count == 1 && parents.First() is Folder folder
&& (folder is Playlist || folder is BoxSet)
&& folder.LinkedChildren.Length > 0)
{
// Playlists and BoxSets store their contents in LinkedChildren and never
// populate AncestorIds for those items, so a recursive AncestorIds query
// would return zero rows. Resolve to the linked child IDs up front and
// route through the existing indexed ItemIds filter.
query.ItemIds = folder.LinkedChildren
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
.Select(lc => lc.ItemId!.Value)
.ToArray();
// Empty linked-children should still return empty rather than scanning everything.
if (query.ItemIds.Length == 0)
{
query.ItemIds = [Guid.NewGuid()];
}
}
else
{
// We need to be able to query from any arbitrary ancestor up the tree

View File

@@ -1593,17 +1593,11 @@ namespace MediaBrowser.Controller.Entities
/// <returns>IEnumerable{BaseItem}.</returns>
public List<BaseItem> GetLinkedChildren()
{
var linkedChildren = LinkedChildren;
var list = new List<BaseItem>(linkedChildren.Length);
foreach (var i in linkedChildren)
var resolved = ResolveLinkedChildren(LinkedChildren);
var list = new List<BaseItem>(resolved.Count);
foreach (var (_, item) in resolved)
{
var child = GetLinkedChild(i);
if (child is not null)
{
list.Add(child);
}
list.Add(item);
}
return list;
@@ -1704,12 +1698,74 @@ namespace MediaBrowser.Controller.Entities
/// <returns>IEnumerable{BaseItem}.</returns>
public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetLinkedChildrenInfos()
{
return LinkedChildren
.Select(i => new Tuple<LinkedChild, BaseItem>(i, GetLinkedChild(i)))
.Where(i => i.Item2 is not null)
return ResolveLinkedChildren(LinkedChildren)
.Select(t => new Tuple<LinkedChild, BaseItem>(t.Info, t.Item))
.ToArray();
}
/// <summary>
/// Resolves a list of <see cref="LinkedChild"/> entries to their <see cref="BaseItem"/> targets,
/// batching the database lookup across all entries with a known ItemId.
/// Entries without a usable ItemId fall back to the per-entry <see cref="BaseItem.GetLinkedChild"/>
/// path (legacy path-based resolution).
/// </summary>
/// <param name="linkedChildren">Linked children to resolve.</param>
/// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns>
private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren)
{
var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count);
if (linkedChildren.Count == 0)
{
return resolved;
}
var idsToBatch = new HashSet<Guid>();
foreach (var info in linkedChildren)
{
if (info.ItemId.HasValue && !info.ItemId.Value.IsEmpty())
{
idsToBatch.Add(info.ItemId.Value);
}
}
Dictionary<Guid, BaseItem> byId = null;
if (idsToBatch.Count > 0)
{
var batched = LibraryManager.GetItemList(new InternalItemsQuery
{
ItemIds = [.. idsToBatch]
});
byId = new Dictionary<Guid, BaseItem>(batched.Count);
foreach (var item in batched)
{
byId[item.Id] = item;
}
}
foreach (var info in linkedChildren)
{
BaseItem item = null;
if (byId is not null && info.ItemId.HasValue && byId.TryGetValue(info.ItemId.Value, out var batchedItem))
{
item = batchedItem;
}
else
{
// ItemId is missing/empty or the batched query couldn't return the item
// (e.g. it has been removed). Fall back to per-entry resolution, which also
// handles legacy path-based linked children.
item = GetLinkedChild(info);
}
if (item is not null)
{
resolved.Add((info, item));
}
}
return resolved;
}
protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
var changesFound = false;

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -6,7 +7,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
@@ -485,16 +485,38 @@ namespace MediaBrowser.LocalMetadata.Savers
return;
}
// Batch-resolve all ItemIds to paths in a single query to avoid an N+1 round-trip per linked child
var idsToResolve = new HashSet<Guid>();
foreach (var link in linkedChildren)
{
if (link.ItemId.HasValue && !link.ItemId.Value.Equals(Guid.Empty))
{
idsToResolve.Add(link.ItemId.Value);
}
}
Dictionary<Guid, string?>? pathById = null;
if (idsToResolve.Count > 0)
{
var batched = LibraryManager.GetItemList(new InternalItemsQuery
{
ItemIds = [.. idsToResolve]
});
pathById = new Dictionary<Guid, string?>(batched.Count);
foreach (var batchedItem in batched)
{
pathById[batchedItem.Id] = batchedItem.Path;
}
}
await writer.WriteStartElementAsync(null, pluralNodeName, null).ConfigureAwait(false);
foreach (var link in linkedChildren)
{
// Resolve ItemId to get the item's path for XML portability
string? path = null;
if (link.ItemId.HasValue && !link.ItemId.Value.Equals(Guid.Empty))
if (pathById is not null && link.ItemId.HasValue && pathById.TryGetValue(link.ItemId.Value, out var resolvedPath))
{
var linkedItem = LibraryManager.GetItemById(link.ItemId.Value);
path = linkedItem?.Path;
path = resolvedPath;
}
if (!string.IsNullOrWhiteSpace(path))

View File

@@ -73,6 +73,10 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
builder.HasIndex(e => e.SeasonId);
builder.HasIndex(e => e.SeriesId);
// Items/Counts: SELECT Type, COUNT(*) GROUP BY Type filtered by TopParentId.
builder.HasIndex(e => new { e.TopParentId, e.Type, e.IsVirtualItem })
.HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
builder.HasData(new BaseItemEntity()
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddPartialIndexForItemCounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
table: "BaseItems",
columns: new[] { "TopParentId", "Type", "IsVirtualItem" },
filter: "\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
table: "BaseItems");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -382,6 +382,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasIndex("Type", "CleanName");
b.HasIndex("TopParentId", "Type", "IsVirtualItem")
.HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
b.HasIndex("Type", "TopParentId", "Id");
b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");