mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-05 00:06:35 +01:00
Fix Playlist and Boxset query and count perf
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user