Files
pgsql-jellyfin/docs/TV_SHOWS_QUERY_PERFORMANCE_ANALYSIS.md
T
wjones 8914f4dce9 Add SQL query patterns documentation and Linux package build scripts
- Created `TV_SHOWS_SQL_QUERY_PATTERNS.md` to document SQL query patterns for TV shows, including performance issues and missing indexes.
- Added `README.md` for Linux package building, detailing steps for creating Debian and Red Hat packages.
- Implemented build scripts for Debian and Red Hat, including service files and post-installation hooks.
- Added necessary scripts for managing Jellyfin service lifecycle on both Debian and Red Hat systems.
- Included package specifications and installation instructions for both distributions.
2026-07-14 10:32:56 -04:00

586 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TV Shows/Series Query Performance Analysis
## Executive Summary
TV Shows queries are **inherently slower** than other item types due to:
1. **Extra table joins** - TvExtras table required for Series grouping/deduplication
2. **Complex filtering logic** - Series-specific handling in IsPlayed filter and tag inheritance
3. **In-memory deduplication** - All matching episodes must be loaded before grouping (N+1 risk)
4. **Missing composite indexes** - No index on `(Type, IsFolder)` for efficient Series filtering
5. **SeriesPresentationUniqueKey deduplication** - Requires loading all results before grouping
---
## 1. TV Shows Query Logic in BaseItemRepository
### Location
[BaseItemRepository.cs](Jellyfin.Server.Implementations/Item/BaseItemRepository.cs#L421)
### Query Flow for TV Shows
**Step 1: PrepareItemQuery** (Line 911)
```csharp
private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter)
{
IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking();
dbQuery = dbQuery.AsSingleQuery();
return dbQuery;
}
```
- Creates a base query from BaseItems table
- `AsSingleQuery()` - Prevents EF Core query splitting (important for TV Shows to avoid N+1)
**Step 2: TranslateQuery** (Line 2577)
- Applies filtering including:
- Type filtering: `e.Type != "Series"` (for excluding Series)
- IsSeries flag: `e.IsSeries == filter.IsSeries`
- IsPlayed special logic (line 2995-3000)
**Step 3: ApplyOrder** - Orders results
**Step 4: ApplyGroupingInMemory** (Line 814)
- **CRITICAL**: Groups results IN MEMORY (not in database)
- Uses `DistinctBy()` with reflection on anonymous types
- For Series: `DistinctBy(e => e.TvExtras!.SeriesPresentationUniqueKey)`
**Step 5: ApplyNavigations** (Line 780)
- Eagerly loads related data via `.Include()`:
```csharp
.Include(e => e.TvExtras) // TV Series metadata
.Include(e => e.LiveTvExtras) // Live TV metadata
.Include(e => e.AudioExtras) // Audio metadata
.Include(e => e.Provider) // Provider IDs
.Include(e => e.LockedFields) // Locked metadata fields
.Include(e => e.UserData) // Watch history (if EnableUserData=true)
.Include(e => e.Images) // Item images
.Include(e => e.TrailerTypes) // Trailer types (if requested)
```
---
## 2. Type Filtering for Series
### Location: Lines 2719-2748
**How Type Filtering Works:**
```csharp
var includeTypes = filter.IncludeItemTypes;
if (filter.IncludeItemTypes.Length == 0)
{
var excludeTypes = filter.ExcludeItemTypes;
if (excludeTypes.Length == 1)
{
baseQuery = baseQuery.Where(e => e.Type != excludeTypeName);
}
else if (excludeTypes.Length > 1)
{
var excludeTypeName = new List<string>();
foreach (var excludeType in excludeTypes)
{
if (_itemTypeLookup.BaseItemKindNames.TryGetValue(excludeType, out var baseItemKindName))
{
excludeTypeName.Add(baseItemKindName!);
}
}
baseQuery = baseQuery.Where(e => !excludeTypeName.Contains(e.Type));
}
}
else
{
string[] types = includeTypes.Select(f => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(f))
.Where(e => e != null).ToArray()!;
baseQuery = baseQuery.WhereOneOrMany(types, f => f.Type);
}
```
**Problem**: No index on `Type` column alone
- When filtering for Series: `e.Type == "Series"`
- Requires table scan or composite index
- **Index Status**: Only composite indexes exist:
- `baseitems_parentid_idx` → `(ParentId, Type)`
- `baseitems_topparentid_idx` → `(TopParentId, Type)`
- **Missing**: Standalone `Type` index or `(IsFolder, Type)` composite
### Workaround Optimization
The code uses `IsFolder` to optimize Series queries since Series are folders:
```csharp
if (filter.IsFolder.HasValue)
{
baseQuery = baseQuery.Where(e => e.IsFolder == filter.IsFolder);
}
```
**But**: This is manual and requires caller to specify `IsFolder=true` when filtering for Series.
---
## 3. Series-Specific Filtering: Child Count & Parent Relationships
### Location: Lines 3416-3438
**SeriesPresentationUniqueKey Filtering** (Line 3416-3419)
```csharp
if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey))
{
baseQuery = baseQuery
.Where(e => e.TvExtras!.SeriesPresentationUniqueKey == filter.SeriesPresentationUniqueKey);
}
```
- Filters episodes by their parent Series
- Requires **join to TvExtras table**
**Tag Inheritance for Episodes** (Line 3424-3438)
```csharp
if (filter.ExcludeInheritedTags.Length > 0)
{
var excludedTags = filter.ExcludeInheritedTags;
baseQuery = baseQuery.Where(e =>
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
&& (!e.TvExtras!.SeriesId.HasValue || !context.ItemValuesMap.Any(f =>
f.ItemId == e.TvExtras!.SeriesId.Value &&
f.ItemValue.Type == ItemValueType.Tags &&
excludedTags.Contains(f.ItemValue.CleanValue))));
}
if (filter.IncludeInheritedTags.Length > 0)
{
var includeTags = filter.IncludeInheritedTags;
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
baseQuery = baseQuery.Where(e =>
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
// For seasons and episodes, we also need to check the parent series' tags.
|| (e.TvExtras!.SeriesId.HasValue && context.ItemValuesMap.Any(f =>
f.ItemId == e.TvExtras!.SeriesId.Value &&
f.ItemValue.Type == ItemValueType.Tags &&
includeTags.Contains(f.ItemValue.CleanValue)))
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
```
**Performance Issue**:
- **For each episode**, the query checks the parent Series' tags
- Requires correlated subqueries in tag filtering
- Forces joins to TvExtras, ItemValuesMap, and ItemValues tables
### IsPlayed Special Handling (Line 2995-3000)
```csharp
if (filter.IsPlayed.HasValue)
{
if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series)
{
// SPECIAL CASE: For Series queries, check if ANY episode was played
baseQuery = baseQuery.Where(e =>
context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId))
.Where(e => e.IsFolder == false && e.IsVirtualItem == false)
.Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played)
.Any(f => f.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed);
}
else
{
// Standard: Check if THIS item was played
baseQuery = baseQuery
.Select(e => new
{
IsPlayed = e.UserData!.Where(f => f.UserId == filter.User!.Id).Select(f => (bool?)f.Played).FirstOrDefault() ?? false,
Item = e
})
.Where(e => e.IsPlayed == filter.IsPlayed)
.Select(f => f.Item);
}
}
```
**The Problem**:
- **Correlated subquery inside WHERE clause**
- For each Series, database checks ALL episodes to see if ANY were played
- This is a **massive performance killer** for Series queries
- Example: 100 Series × (each checks all episodes in library) = thousands of subqueries
---
## 4. N+1 Loading Patterns Specific to TV Shows
### Location: Lines 450-530 (GetItemsAsync)
**The Two-Query Pattern:**
**Query 1: Get IDs with Keys** (Line 455-460)
```csharp
var allIds = await dbQuery
.Select(e => new { e.Id, e.PresentationUniqueKey, SeriesPresentationUniqueKey = e.TvExtras!.SeriesPresentationUniqueKey })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
```
- Gets **all matching rows** (including duplicates)
- Must load TvExtras for grouping
**Query 2: Load Full Entities** (Line 475-481)
```csharp
var dbQueryWithNavs = ApplyNavigations(context.BaseItems.Where(e => pagedIds.Contains(e.Id)), filter);
var items = await dbQueryWithNavs
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
```
- **Second query**: Loads full entities including all navigations
**The N+1 Risk for TV Shows:**
1. User has 500 Series with 10,000 Episodes total
2. When browsing "TV Shows" page:
- User clicks "TV Shows" → wants to see list of Series (not episodes)
- Query 1 loads ALL 10,000 episodes (to deduplicate by SeriesPresentationUniqueKey)
- Only then groups to 500 Series
- Query 2 loads 500 Series entities with all their data
3. If subsequent calls ask for "child count" or "episode count" per Series → additional queries per Series
### Why This is Different Than Movies/Music
- **Movies**: No grouping needed, 1:1 with items displayed
- **Music Albums**: Grouped by Album, but typically fewer duplicates
- **TV Shows**: 1 Series + N Episodes = N+1 rows to load and deduplicate
---
## 5. In-Memory Deduplication (ApplyGroupingInMemory)
### Location: Lines 814-850
```csharp
private List<Guid> ApplyGroupingInMemory<T>(List<T> items, InternalItemsQuery filter)
where T : class
{
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
var idProp = typeof(T).GetProperty("Id");
var presentationKeyProp = typeof(T).GetProperty("PresentationUniqueKey");
var seriesKeyProp = typeof(T).GetProperty("SeriesPresentationUniqueKey");
IEnumerable<T> filtered = items;
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{
filtered = items.DistinctBy(e => new
{
PresentationKey = presentationKeyProp.GetValue(e),
SeriesKey = seriesKeyProp.GetValue(e)
});
}
else if (enableGroupByPresentationUniqueKey)
{
filtered = items.DistinctBy(e => presentationKeyProp.GetValue(e));
}
else if (filter.GroupBySeriesPresentationUniqueKey)
{
filtered = items.DistinctBy(e => seriesKeyProp.GetValue(e));
}
else
{
filtered = items.DistinctBy(e => idProp.GetValue(e));
}
return filtered.Select(e => (Guid)idProp.GetValue(e)!).ToList();
}
```
**Why Not Database Grouping?**
- Comment in code: "See docs/query-optimization-complete-story.md"
- **EF Core limitation**: `DistinctBy()` on complex types cannot be translated to SQL
- **PostgreSQL issue**: UUID constraints prevent some grouping patterns
- **Workaround**: Use `DISTINCT ON` via `DistinctBy()` in EF Core (translates correctly)
- But `DistinctBy()` within complex SELECT is not always translatable
**Performance Impact for TV Shows:**
- Must load **ALL matching episodes** from database
- Then deduplicate in C# code using reflection
- For large libraries: 10,000+ rows loaded just to get 500 unique Series
- **Memory spike**: Temporary list of 10,000 items before deduplication
---
## 6. Database Indexes: What Exists vs. What's Missing
### Current Base Indexes on BaseItems
[Migration: 20260226170000_AddBasePerformanceIndexes.cs](src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/Migrations/20260226170000_AddBasePerformanceIndexes.cs)
**Indexes That Support Series Queries:**
| Index | Columns | Supports |
|-------|---------|----------|
| `baseitems_parentid_idx` | `(ParentId, Type)` | Series grouped by parent folder |
| `baseitems_topparentid_idx` | `(TopParentId, Type)` | Series filtered by library |
| `baseitems_seriespresentationuniquekey_idx` | `(SeriesPresentationUniqueKey, IndexNumber, ParentIndexNumber)` | ✅ Episodes by Series |
| `baseitems_sortname_idx` | `(SortName)` | Series sorted by name |
| `baseitems_premieredate_idx` | `(PremiereDate DESC)` | Series sorted by date |
**Critical Indexes Missing:**
| Index | Impact | Why Missing |
|-------|--------|------------|
| `Type` (standalone) | Full table scan when filtering by Type alone | Not included in migration |
| `(IsFolder, Type)` | Fast Series queries without ParentId filter | Not included in migration |
| `(Type, IsFolder)` | Fast queries: "all Series" without folder hierarchy | Not included in migration |
| `Type` with filter on `IsSeries=true` | IsSeries column not indexed | Not included in migration |
**Why This Matters:**
```sql
-- Current: Uses composite index (ParentId, Type) if available
SELECT * FROM BaseItems WHERE ParentId = $1 AND Type = 'Series';
-- Missing: NO index for this common query pattern
SELECT * FROM BaseItems WHERE Type = 'Series';
-- Missing: NO index for this optimization
SELECT * FROM BaseItems WHERE IsFolder = true AND Type = 'Series';
```
### TvExtras Table Indexes
- **Missing**: Index on `SeriesId` (needed for tag inheritance queries)
- **Missing**: Composite `(ItemId, SeriesPresentationUniqueKey)`
---
## 7. ApplyGroupingFilter Logic
### Location: Lines 740-778
```csharp
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{
// PERFORMANCE FIX: Use DistinctBy() instead of GroupBy().Select().FirstOrDefault()
dbQuery = dbQuery.DistinctBy(e => new { e.PresentationUniqueKey, SeriesPresentationUniqueKey = e.TvExtras!.SeriesPresentationUniqueKey });
}
else if (enableGroupByPresentationUniqueKey)
{
// PERFORMANCE FIX: Use DistinctBy() to generate DISTINCT ON instead of correlated subquery
dbQuery = dbQuery.DistinctBy(e => e.PresentationUniqueKey);
}
else if (filter.GroupBySeriesPresentationUniqueKey)
{
// PERFORMANCE FIX: Use DistinctBy() instead of GroupBy().Select().FirstOrDefault()
dbQuery = dbQuery.DistinctBy(e => e.TvExtras!.SeriesPresentationUniqueKey);
}
else
{
dbQuery = dbQuery.Distinct();
}
dbQuery = ApplyOrder(dbQuery, filter, context);
return dbQuery;
}
```
**Key Insight**:
- `DistinctBy()` translates to PostgreSQL's `DISTINCT ON` clause
- This is **much faster** than correlated subqueries
- But still must fetch all matching rows first
**For Series Queries:**
- `GroupBySeriesPresentationUniqueKey=true`
- Translates to: `SELECT DISTINCT ON (TvExtras.SeriesPresentationUniqueKey) ...`
- This means: "Give me one row per unique Series"
**The Problem:**
- Must ORDER BY before using DISTINCT ON (PostgreSQL requirement)
- With many Episodes, sorting 10,000 rows then deduplicating is slower than database-level grouping
---
## 8. Complete Series Query Execution Example
### Scenario: User Clicks "TV Shows" in Library
**URL**: `/Items?IncludeItemTypes=Series&ParentId=X`
**What Happens:**
```
1. GetItemsAsync() called with:
- IncludeItemTypes: [Series]
- ParentId: X (TV Shows folder)
- EnableTotalRecordCount: true
2. PrepareItemQuery()
└─ IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking().AsSingleQuery()
3. TranslateQuery()
└─ WHERE Type = 'Series'
└─ WHERE ParentId = X
└─ (If indexed: uses baseitems_parentid_idx)
4. ApplyOrder()
└─ ORDER BY SortName (or other sort)
5. QUERY 1 (Lines 455-460):
SELECT Id, PresentationUniqueKey, TvExtras.SeriesPresentationUniqueKey
FROM BaseItems
LEFT JOIN TvExtras ON BaseItems.Id = TvExtras.ItemId
WHERE ParentId = X AND Type = 'Series'
ORDER BY SortName
└─ Result: 500 Series rows (1 row per Series, TvExtras joined)
6. ApplyGroupingInMemory()
└─ Already unique, no deduplication needed for Series-only queries
7. ApplyPagingToIds()
└─ Skip 0, Take 100 (if paginated)
8. QUERY 2 (Lines 475-481):
SELECT * FROM BaseItems
LEFT JOIN TvExtras ON BaseItems.Id = TvExtras.ItemId
LEFT JOIN Provider ON BaseItems.Id = Provider.ItemId
LEFT JOIN Images ON BaseItems.Id = Images.ItemId
LEFT JOIN UserData ON BaseItems.Id = UserData.ItemId
WHERE Id IN (500 Series IDs from paged list)
└─ Result: Full Series entities with all related data
9. Deserialize to DTOs
└─ Convert BaseItemEntity to BaseItemDto
└─ Map UserData if available
10. Return to client
```
**Total Queries: 2**
- **Query 1**: Get Series IDs (fast, uses index)
- **Query 2**: Get full entities (includes multiple JOINs)
**Bottleneck**:
- If there are 10,000 Episodes with same Parent, Query 1 returns 10,000 rows (one per episode), then deduplicates
- This is rare for direct Series queries, but happens when:
- Querying Episodes: `IncludeItemTypes=Episode&ParentId=X`
- Querying all content: `IncludeItemTypes=[Series,Episode,Movie]`
---
## 9. Performance Comparison: Series vs. Movies vs. Music
### Query Pattern Differences
**Movies** (Fast):
```csharp
// No special handling, 1:1 with display
WHERE Type = 'Movie'
// Result: 100 rows = 100 Movies shown
```
**Music Albums** (Medium):
```csharp
// Grouped by Album name
WHERE Type = 'MusicAlbum'
SELECT DISTINCT ON (Album) ...
// Result: 100 rows = 100 Albums
```
**TV Shows** (Slow):
```csharp
// Two cases:
// Case 1: Query for Series themselves
WHERE Type = 'Series'
// Result: 500 rows = 500 Series (GOOD)
// Case 2: Query for all Episodes (common in "latest" or "browse all")
WHERE Type = 'Episode'
SELECT DISTINCT ON (TvExtras.SeriesPresentationUniqueKey) ...
// Result: 10,000 rows → deduplicate to 500 → return 100
// PROBLEM: Loaded 10,000, returned 100
```
### Why Series Are Slower
| Aspect | Movies | TV Shows | Impact |
|--------|--------|----------|--------|
| Deduplication | None | Required (Series ID) | 10-100x more rows loaded |
| Table joins | 1 (BaseItems) | 3+ (BaseItems + TvExtras + Tags checks) | Multiple JOINs per query |
| Filter complexity | Simple | Complex (tag inheritance, IsPlayed special case) | More CPU, more conditions |
| Index usage | Direct on Type | Composite (ParentId, Type) usually needed | Fewer index options |
| Related data | Simple | Children (episodes), parent (series for episodes) | More eager-load navigations |
| In-memory ops | Minimal | Full list loaded for deduplication | Memory spike |
---
## 10. Special Handling That Slows TV Shows
### 1. IsPlayed Special Case (Line 2995)
```csharp
if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series)
{
// Correlated subquery: For each Series, check if ANY episode was played
baseQuery = baseQuery.Where(e =>
context.BaseItems
.Where(f => f.TvExtras!.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Any(f => f.UserData!.FirstOrDefault()!.Played));
}
```
**Impact**: Extra subquery per Series
### 2. Tag Inheritance for Episodes (Line 3427-3438)
```csharp
// Check episode's tags AND parent series' tags
&& (!e.TvExtras!.SeriesId.HasValue ||
!context.ItemValuesMap.Any(f =>
f.ItemId == e.TvExtras!.SeriesId.Value &&
f.ItemValue.Type == ItemValueType.Tags))
```
**Impact**: Joins to ItemValuesMap for every tag filter on episodes
### 3. AsSingleQuery() (Line 914)
```csharp
dbQuery = dbQuery.AsSingleQuery();
```
**Impact**: Prevents EF Core from splitting queries, but means all JOINs happen in one huge query
- Better: Avoids N+1
- Worse: One massive JOIN chain for all navigations
---
## 11. Recommendations for Optimization
### Immediate (No Schema Changes)
1. **Add Index on Type column**
```sql
CREATE INDEX CONCURRENTLY idx_baseitems_type
ON library."BaseItems" ("Type");
```
2. **Add Composite Index for IsFolder + Type**
```sql
CREATE INDEX CONCURRENTLY idx_baseitems_isfolder_type
ON library."BaseItems" ("IsFolder", "Type");
```
3. **Add Index on TvExtras.SeriesId**
```sql
CREATE INDEX CONCURRENTLY idx_baseitem_tv_extras_seriesid
ON library."BaseItemTvExtras" ("SeriesId");
```
### Short-term (Application Changes)
1. **Cache Series child counts** to avoid repeated queries
2. **Lazy-load TvExtras** only when needed (not always via Include)
3. **Optimize IsPlayed query** for Series - precompute in separate query
### Long-term (Architecture Changes)
1. **Denormalize Series child count** into BaseItems table
2. **Create materialized view** for Series with episode count, last played date
3. **Use read-only replica** for expensive Series queries
4. **Implement Series-specific cache layer** in BaseItemRepository
---
## Summary: Why TV Shows Are Slower
| Factor | Impact Level | Reason |
|--------|--------------|--------|
| **Extra Table Joins** | ⭐⭐⭐⭐⭐ | TvExtras, tag checks, user data |
| **In-Memory Deduplication** | ⭐⭐⭐⭐ | All episodes loaded, deduplicated in C# |
| **Missing Indexes** | ⭐⭐⭐⭐ | No Type or (IsFolder, Type) indexes |
| **Correlated Subqueries** | ⭐⭐⭐⭐ | IsPlayed, tag inheritance, child counts |
| **Complex Filtering** | ⭐⭐⭐ | Tag inheritance from parent Series |
| **AsSingleQuery()** | ⭐⭐ | One huge JOIN chain, not optimizable |
**Total Impact**: TV Shows queries can be **10-100x slower** than Movies, depending on library size and query type.