< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.FixIncorrectOwnerIdRelationships
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 183
Coverable lines: 183
Total lines: 360
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 54
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/4/2026 - 12:15:16 AM Line coverage: 0% (0/175) Branch coverage: 0% (0/48) Total lines: 3416/27/2026 - 12:15:59 AM Line coverage: 0% (0/183) Branch coverage: 0% (0/54) Total lines: 360 5/4/2026 - 12:15:16 AM Line coverage: 0% (0/175) Branch coverage: 0% (0/48) Total lines: 3416/27/2026 - 12:15:59 AM Line coverage: 0% (0/183) Branch coverage: 0% (0/54) Total lines: 360

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
PerformAsync()100%210%
RemoveDuplicateItemsAsync()0%506220%
ClearIncorrectOwnerIdsAsync()0%2040%
ReassignOrphanedExtrasAsync()0%342180%
PopulatePrimaryVersionIdAsync()0%110100%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Threading;
 5using System.Threading.Tasks;
 6using Jellyfin.Database.Implementations;
 7using Jellyfin.Server.ServerSetupApp;
 8using MediaBrowser.Controller.Library;
 9using MediaBrowser.Controller.Persistence;
 10using Microsoft.EntityFrameworkCore;
 11using Microsoft.Extensions.Logging;
 12
 13namespace Jellyfin.Server.Migrations.Routines;
 14
 15/// <summary>
 16/// Fixes incorrect OwnerId relationships where video/movie items are children of other video/movie items.
 17/// These are alternate versions (4K vs 1080p) that were incorrectly linked as parent-child relationships
 18/// by the auto-merge logic. Only legitimate extras (trailers, behind-the-scenes) should have OwnerId set.
 19/// Also removes duplicate database entries for the same file path.
 20/// </summary>
 21[JellyfinMigration("2026-01-15T12:00:00", nameof(FixIncorrectOwnerIdRelationships))]
 22[JellyfinMigrationBackup(JellyfinDb = true)]
 23public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
 24{
 25    private readonly IStartupLogger<FixIncorrectOwnerIdRelationships> _logger;
 26    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
 27    private readonly ILibraryManager _libraryManager;
 28    private readonly IItemPersistenceService _persistenceService;
 29
 30    /// <summary>
 31    /// Initializes a new instance of the <see cref="FixIncorrectOwnerIdRelationships"/> class.
 32    /// </summary>
 33    /// <param name="logger">The startup logger.</param>
 34    /// <param name="dbContextFactory">The database context factory.</param>
 35    /// <param name="libraryManager">The library manager.</param>
 36    /// <param name="persistenceService">The item persistence service.</param>
 37    public FixIncorrectOwnerIdRelationships(
 38        IStartupLogger<FixIncorrectOwnerIdRelationships> logger,
 39        IDbContextFactory<JellyfinDbContext> dbContextFactory,
 40        ILibraryManager libraryManager,
 41        IItemPersistenceService persistenceService)
 42    {
 043        _logger = logger;
 044        _dbContextFactory = dbContextFactory;
 045        _libraryManager = libraryManager;
 046        _persistenceService = persistenceService;
 047    }
 48
 49    /// <inheritdoc/>
 50    public async Task PerformAsync(CancellationToken cancellationToken)
 51    {
 052        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 053        await using (context.ConfigureAwait(false))
 54        {
 55            // Step 1: Find and remove duplicate database entries (same Path, different IDs)
 056            await RemoveDuplicateItemsAsync(context, cancellationToken).ConfigureAwait(false);
 57
 58            // Step 2: Clear incorrect OwnerId for video/movie items that are children of other video/movie items
 059            await ClearIncorrectOwnerIdsAsync(context, cancellationToken).ConfigureAwait(false);
 60
 61            // Step 3: Reassign orphaned extras to correct parents
 062            await ReassignOrphanedExtrasAsync(context, cancellationToken).ConfigureAwait(false);
 63
 64            // Step 4: Populate PrimaryVersionId for alternate version children
 065            await PopulatePrimaryVersionIdAsync(context, cancellationToken).ConfigureAwait(false);
 66        }
 067    }
 68
 69    private async Task RemoveDuplicateItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
 70    {
 71        // Find all paths that have duplicate entries
 072        var duplicatePaths = await context.BaseItems
 073            .Where(b => b.Path != null)
 074            .GroupBy(b => b.Path)
 075            .Where(g => g.Count() > 1)
 076            .Select(g => g.Key)
 077            .ToListAsync(cancellationToken)
 078            .ConfigureAwait(false);
 79
 080        if (duplicatePaths.Count == 0)
 81        {
 082            _logger.LogInformation("No duplicate items found, skipping duplicate removal.");
 083            return;
 84        }
 85
 086        _logger.LogInformation("Found {Count} paths with duplicate database entries", duplicatePaths.Count);
 87
 88        // Collect all duplicate IDs to delete in one batch
 089        var allIdsToDelete = new List<Guid>();
 90        const int progressLogStep = 500;
 091        var processedPaths = 0;
 092        foreach (var path in duplicatePaths)
 93        {
 094            cancellationToken.ThrowIfCancellationRequested();
 95
 096            if (processedPaths > 0 && processedPaths % progressLogStep == 0)
 97            {
 098                _logger.LogInformation("Resolving duplicates: {Processed}/{Total} paths", processedPaths, duplicatePaths
 99            }
 100
 0101            processedPaths++;
 102
 103            // Get all items with this path
 0104            var itemsWithPath = await context.BaseItems
 0105                .Where(b => b.Path == path)
 0106                .Select(b => new
 0107                {
 0108                    b.Id,
 0109                    b.Type,
 0110                    b.DateCreated,
 0111                    HasOwnedExtras = context.BaseItems.Any(c => c.OwnerId.HasValue && c.OwnerId.Value.Equals(b.Id)),
 0112                    HasDirectChildren = context.BaseItems.Any(c => c.ParentId.HasValue && c.ParentId.Value.Equals(b.Id))
 0113                })
 0114                .ToListAsync(cancellationToken)
 0115                .ConfigureAwait(false);
 116
 0117            if (itemsWithPath.Count <= 1)
 118            {
 119                continue;
 120            }
 121
 122            // Keep the item that has direct children, then owned extras, then prefer non-Folder types, then newest
 0123            var itemToKeep = itemsWithPath
 0124                .OrderByDescending(i => i.HasDirectChildren)
 0125                .ThenByDescending(i => i.HasOwnedExtras)
 0126                .ThenByDescending(i => i.Type != "MediaBrowser.Controller.Entities.Folder")
 0127                .ThenByDescending(i => i.DateCreated)
 0128                .First();
 0129            if (itemToKeep is null)
 130            {
 131                continue;
 132            }
 133
 0134            allIdsToDelete.AddRange(itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id));
 0135        }
 136
 0137        if (allIdsToDelete.Count > 0)
 138        {
 0139            _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count);
 140
 141            // Delete in batches so progress is visible (item resolution and deletion can take a
 142            // long time on large libraries) and so we never issue one massive delete transaction.
 143            const int deleteBatchSize = 500;
 0144            var deletedSoFar = 0;
 0145            for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize)
 146            {
 0147                cancellationToken.ThrowIfCancellationRequested();
 148
 0149                var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset))
 150
 151                // Resolve items for metadata path cleanup, then delete this batch
 0152                var itemsToDelete = batchIds
 0153                    .Select(id => _libraryManager.GetItemById(id))
 0154                    .Where(item => item is not null)
 0155                    .ToList();
 0156                if (itemsToDelete.Count > 0)
 157                {
 0158                    _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
 159                }
 160
 161                // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
 0162                var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
 0163                var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
 0164                if (unresolvedIds.Count > 0)
 165                {
 0166                    _persistenceService.DeleteItem(unresolvedIds);
 167                }
 168
 0169                deletedSoFar += batchIds.Count;
 0170                _logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Coun
 171            }
 172        }
 173
 0174        _logger.LogInformation("Successfully removed {Count} duplicate database entries", allIdsToDelete.Count);
 0175    }
 176
 177    private async Task ClearIncorrectOwnerIdsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
 178    {
 179        // Find video/movie items with incorrect OwnerId (ExtraType is NULL or 0, pointing to another video/movie)
 0180        var incorrectChildrenWithParent = await context.BaseItems
 0181            .Where(b => b.OwnerId.HasValue
 0182                && (b.ExtraType == null || b.ExtraType == 0)
 0183                && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Mo
 0184            .Where(b => context.BaseItems.Any(parent =>
 0185                parent.Id.Equals(b.OwnerId!.Value)
 0186                && (parent.Type == "MediaBrowser.Controller.Entities.Video" || parent.Type == "MediaBrowser.Controller.E
 0187            .ToListAsync(cancellationToken)
 0188            .ConfigureAwait(false);
 189
 190        // Also find orphaned items (parent doesn't exist)
 0191        var orphanedChildren = await context.BaseItems
 0192            .Where(b => b.OwnerId.HasValue
 0193                && (b.ExtraType == null || b.ExtraType == 0)
 0194                && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Mo
 0195            .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
 0196            .ToListAsync(cancellationToken)
 0197            .ConfigureAwait(false);
 198
 0199        var totalIncorrect = incorrectChildrenWithParent.Count + orphanedChildren.Count;
 0200        if (totalIncorrect == 0)
 201        {
 0202            _logger.LogInformation("No items with incorrect OwnerId found, skipping OwnerId cleanup.");
 0203            return;
 204        }
 205
 0206        _logger.LogInformation(
 0207            "Found {Count} video/movie items with incorrect OwnerId relationships ({WithParent} with parent, {Orphaned} 
 0208            totalIncorrect,
 0209            incorrectChildrenWithParent.Count,
 0210            orphanedChildren.Count);
 211
 212        // Clear OwnerId for all incorrect items
 0213        var allIncorrectItems = incorrectChildrenWithParent.Concat(orphanedChildren).ToList();
 0214        foreach (var item in allIncorrectItems)
 215        {
 0216            item.OwnerId = null;
 217        }
 218
 0219        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 0220        _logger.LogInformation("Successfully cleared OwnerId for {Count} items", totalIncorrect);
 0221    }
 222
 223    private async Task ReassignOrphanedExtrasAsync(JellyfinDbContext context, CancellationToken cancellationToken)
 224    {
 225        // Find extras whose parent was deleted during duplicate removal
 0226        var orphanedExtras = await context.BaseItems
 0227            .Where(b => b.ExtraType != null && b.ExtraType != 0 && b.OwnerId.HasValue)
 0228            .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value)))
 0229            .ToListAsync(cancellationToken)
 0230            .ConfigureAwait(false);
 231
 0232        if (orphanedExtras.Count == 0)
 233        {
 0234            _logger.LogInformation("No orphaned extras found, skipping reassignment.");
 0235            return;
 236        }
 237
 0238        _logger.LogInformation("Found {Count} orphaned extras to reassign", orphanedExtras.Count);
 239        const int extraProgressLogStep = 500;
 240
 241        // Build a lookup of directory -> first video/movie item for parent resolution
 0242        var extraDirectories = orphanedExtras
 0243            .Where(e => !string.IsNullOrEmpty(e.Path))
 0244            .Select(e => System.IO.Path.GetDirectoryName(e.Path))
 0245            .Where(d => !string.IsNullOrEmpty(d))
 0246            .Distinct()
 0247            .ToList();
 248
 249        // Load all potential parent video/movies with paths in one query
 0250        var videoTypes = new[]
 0251        {
 0252            "MediaBrowser.Controller.Entities.Video",
 0253            "MediaBrowser.Controller.Entities.Movies.Movie"
 0254        };
 0255        var potentialParents = await context.BaseItems
 0256            .Where(b => b.Path != null && videoTypes.Contains(b.Type))
 0257            .Select(b => new { b.Id, b.Path })
 0258            .ToListAsync(cancellationToken)
 0259            .ConfigureAwait(false);
 260
 261        // Build directory -> parent ID mapping
 0262        var dirToParent = new Dictionary<string, Guid>();
 0263        foreach (var dir in extraDirectories)
 264        {
 0265            var parent = potentialParents
 0266                .Where(p => p.Path!.StartsWith(dir!, StringComparison.OrdinalIgnoreCase))
 0267                .OrderBy(p => p.Id)
 0268                .FirstOrDefault();
 0269            if (parent is not null)
 270            {
 0271                dirToParent[dir!] = parent.Id;
 272            }
 273        }
 274
 0275        var reassignedCount = 0;
 0276        var processedExtras = 0;
 0277        foreach (var extra in orphanedExtras)
 278        {
 0279            if (processedExtras > 0 && processedExtras % extraProgressLogStep == 0)
 280            {
 0281                _logger.LogInformation("Reassigning orphaned extras: {Processed}/{Total}", processedExtras, orphanedExtr
 282            }
 283
 0284            processedExtras++;
 285
 0286            if (string.IsNullOrEmpty(extra.Path))
 287            {
 288                continue;
 289            }
 290
 0291            var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path);
 0292            if (!string.IsNullOrEmpty(extraDirectory) && dirToParent.TryGetValue(extraDirectory, out var parentId))
 293            {
 0294                extra.OwnerId = parentId;
 0295                reassignedCount++;
 296            }
 297            else
 298            {
 0299                extra.OwnerId = null;
 300            }
 301        }
 302
 0303        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 0304        _logger.LogInformation("Successfully reassigned {Count} orphaned extras", reassignedCount);
 0305    }
 306
 307    private async Task PopulatePrimaryVersionIdAsync(JellyfinDbContext context, CancellationToken cancellationToken)
 308    {
 309        // Find all alternate version relationships where child's PrimaryVersionId is not set
 310        // ChildType 2 = LocalAlternateVersion, ChildType 3 = LinkedAlternateVersion
 0311        var alternateVersionLinks = await context.LinkedChildren
 0312            .Where(lc => (lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersi
 0313                       || lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LinkedAlternateVers
 0314            .Join(
 0315                context.BaseItems,
 0316                lc => lc.ChildId,
 0317                item => item.Id,
 0318                (lc, item) => new { lc.ParentId, lc.ChildId, item.PrimaryVersionId })
 0319            .Where(x => !x.PrimaryVersionId.HasValue || !x.PrimaryVersionId.Value.Equals(x.ParentId))
 0320            .ToListAsync(cancellationToken)
 0321            .ConfigureAwait(false);
 322
 0323        if (alternateVersionLinks.Count == 0)
 324        {
 0325            _logger.LogInformation("No alternate version items need PrimaryVersionId population, skipping.");
 0326            return;
 327        }
 328
 0329        _logger.LogInformation("Found {Count} alternate version items that need PrimaryVersionId populated", alternateVe
 330
 331        // Batch-load all child items in a single query
 0332        var childIds = alternateVersionLinks.Select(l => l.ChildId).Distinct().ToList();
 0333        var childItems = await context.BaseItems
 0334            .WhereOneOrMany(childIds, b => b.Id)
 0335            .ToDictionaryAsync(b => b.Id, cancellationToken)
 0336            .ConfigureAwait(false);
 337
 0338        var updatedCount = 0;
 339        const int linkProgressLogStep = 1000;
 0340        var processedLinks = 0;
 0341        foreach (var link in alternateVersionLinks)
 342        {
 0343            if (processedLinks > 0 && processedLinks % linkProgressLogStep == 0)
 344            {
 0345                _logger.LogInformation("Populating PrimaryVersionId: {Processed}/{Total} links", processedLinks, alterna
 346            }
 347
 0348            processedLinks++;
 349
 0350            if (childItems.TryGetValue(link.ChildId, out var childItem))
 351            {
 0352                childItem.PrimaryVersionId = link.ParentId;
 0353                updatedCount++;
 354            }
 355        }
 356
 0357        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 0358        _logger.LogInformation("Successfully populated PrimaryVersionId for {Count} alternate version items", updatedCou
 0359    }
 360}