< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.MigrateLinkedChildren
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
Line coverage
48%
Covered lines: 198
Uncovered lines: 208
Coverable lines: 406
Total lines: 818
Line coverage: 48.7%
Branch coverage
33%
Covered branches: 53
Total branches: 160
Branch coverage: 33.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 0% (0/308) Branch coverage: 0% (0/150) Total lines: 6156/9/2026 - 12:16:23 AM Line coverage: 0% (0/325) Branch coverage: 0% (0/154) Total lines: 6448/2/2026 - 12:17:28 AM Line coverage: 0% (0/358) Branch coverage: 0% (0/132) Total lines: 70010/29/2026 - 12:17:11 AM Line coverage: 48.7% (198/406) Branch coverage: 33.1% (53/160) Total lines: 818 5/16/2026 - 12:15:55 AM Line coverage: 0% (0/308) Branch coverage: 0% (0/150) Total lines: 6156/9/2026 - 12:16:23 AM Line coverage: 0% (0/325) Branch coverage: 0% (0/154) Total lines: 6448/2/2026 - 12:17:28 AM Line coverage: 0% (0/358) Branch coverage: 0% (0/132) Total lines: 70010/29/2026 - 12:17:11 AM Line coverage: 48.7% (198/406) Branch coverage: 33.1% (53/160) Total lines: 818

Coverage delta

Coverage delta 49 -49

Metrics

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.IO;
 4using System.Linq;
 5using System.Text.Json;
 6using Jellyfin.Database.Implementations;
 7using Jellyfin.Database.Implementations.Entities;
 8using Jellyfin.Extensions;
 9using MediaBrowser.Controller;
 10using MediaBrowser.Controller.Entities;
 11using MediaBrowser.Controller.Library;
 12using MediaBrowser.Model.Entities;
 13using Microsoft.EntityFrameworkCore;
 14using Microsoft.Extensions.Logging;
 15using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
 16
 17namespace Jellyfin.Server.Migrations.Routines;
 18
 19/// <summary>
 20/// Migrates LinkedChildren data from JSON Data column to the LinkedChildren table.
 21/// </summary>
 22[JellyfinMigration("2026-01-13T12:00:00", nameof(MigrateLinkedChildren))]
 23[JellyfinMigrationBackup(JellyfinDb = true)]
 24internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
 25{
 26    private const int ParseProgressLogStep = 25_000;
 27    private const int FileCheckProgressLogStep = 10_000;
 28    private const int ResolveProgressLogStep = 10_000;
 29    private const int DeleteProgressLogStep = 25;
 30    private const string CollectionFolderTypeName = "MediaBrowser.Controller.Entities.CollectionFolder";
 31
 32    private readonly ILogger<MigrateLinkedChildren> _logger;
 33    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 34    private readonly ILibraryManager _libraryManager;
 35    private readonly IServerApplicationHost _appHost;
 36    private readonly IServerApplicationPaths _appPaths;
 37
 38    public MigrateLinkedChildren(
 39        ILoggerFactory loggerFactory,
 40        IDbContextFactory<JellyfinDbContext> dbProvider,
 41        ILibraryManager libraryManager,
 42        IServerApplicationHost appHost,
 43        IServerApplicationPaths appPaths)
 44    {
 645        _logger = loggerFactory.CreateLogger<MigrateLinkedChildren>();
 646        _dbProvider = dbProvider;
 647        _libraryManager = libraryManager;
 648        _appHost = appHost;
 649        _appPaths = appPaths;
 650    }
 51
 52    /// <inheritdoc/>
 53    public void Perform()
 54    {
 655        using var context = _dbProvider.CreateDbContext();
 56
 657        var containerTypes = new[]
 658        {
 659            "MediaBrowser.Controller.Entities.Movies.BoxSet",
 660            "MediaBrowser.Controller.Playlists.Playlist",
 661            "MediaBrowser.Controller.Entities.CollectionFolder"
 662        };
 63
 664        var videoTypes = new[]
 665        {
 666            "MediaBrowser.Controller.Entities.Video",
 667            "MediaBrowser.Controller.Entities.Movies.Movie",
 668            "MediaBrowser.Controller.Entities.TV.Episode"
 669        };
 70
 671        var itemsWithData = context.BaseItems
 672            .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
 673            .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder })
 674            .ToList();
 75
 676        _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count
 77
 678        var pathToIdMap = context.BaseItems
 679            .Where(b => b.Path != null)
 680            .Select(b => new { b.Id, b.Path })
 681            .GroupBy(b => b.Path!)
 682            .ToDictionary(g => g.Key, g => g.First().Id);
 83
 84        // Needed to tell a stale cached ItemId apart from one that still points at a real item.
 685        var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet();
 86
 687        var playlistParentIds = itemsWithData
 688            .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist")
 689            .Select(b => b.Id)
 690            .ToHashSet();
 91
 692        var droppedChildren = 0;
 693        var linkedChildrenToAdd = new List<LinkedChildEntity>();
 694        var processedCount = 0;
 695        var totalItems = itemsWithData.Count;
 96
 1297        foreach (var item in itemsWithData)
 98        {
 099            if (string.IsNullOrEmpty(item.Data))
 100            {
 101                continue;
 102            }
 103
 0104            if (processedCount > 0 && processedCount % ParseProgressLogStep == 0)
 105            {
 0106                _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItem
 107            }
 108
 109            try
 110            {
 0111                using var doc = JsonDocument.Parse(item.Data);
 112
 0113                var isVideo = videoTypes.Contains(item.Type);
 114
 115                // Handle Video alternate versions
 0116                if (isVideo)
 117                {
 0118                    ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd
 119                }
 120
 121                // Handle LinkedChildren (for containers and other items)
 0122                if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenEl
 123                {
 0124                    processedCount++;
 0125                    continue;
 126                }
 127
 128                // Legacy entries may hold a path relative to the container that holds them, so the
 129                // container's own location has to be a real path, not a virtual one.
 0130                var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path);
 0131                var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath);
 0132                var sortOrder = 0;
 0133                foreach (var childElement in linkedChildrenElement.EnumerateArray())
 134                {
 0135                    var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds);
 0136                    if (!childId.HasValue)
 137                    {
 0138                        droppedChildren++;
 0139                        _logger.LogWarning(
 0140                            "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}",
 0141                            item.Id,
 0142                            GetStringProperty(childElement, "ItemId") ?? "none",
 0143                            GetStringProperty(childElement, "Path") ?? "none");
 0144                        continue;
 145                    }
 146
 0147                    var childType = LinkedChildType.Manual;
 0148                    if (childElement.TryGetProperty("Type", out var typeProp))
 149                    {
 0150                        if (typeProp.ValueKind == JsonValueKind.Number)
 151                        {
 0152                            childType = (LinkedChildType)typeProp.GetInt32();
 153                        }
 0154                        else if (typeProp.ValueKind == JsonValueKind.String)
 155                        {
 0156                            var typeStr = typeProp.GetString();
 0157                            if (Enum.TryParse<LinkedChildType>(typeStr, out var parsedType))
 158                            {
 0159                                childType = parsedType;
 160                            }
 161                        }
 162                    }
 163
 0164                    linkedChildrenToAdd.Add(new LinkedChildEntity
 0165                    {
 0166                        ParentId = item.Id,
 0167                        ChildId = childId.Value,
 0168                        ChildType = childType,
 0169                        SortOrder = sortOrder
 0170                    });
 171
 0172                    sortOrder++;
 173                }
 174
 0175                processedCount++;
 0176            }
 0177            catch (JsonException ex)
 178            {
 0179                _logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id);
 0180            }
 181        }
 182
 6183        if (linkedChildrenToAdd.Count > 0)
 184        {
 0185            _logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count);
 186
 0187            var existingKeys = context.LinkedChildren
 0188                .Select(lc => new { lc.ParentId, lc.ChildId })
 0189                .ToHashSet();
 190
 191            // A playlist may list the same child more than once, so it cannot be keyed by
 192            // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which
 193            // keeps the routine re-runnable without collapsing repeated entries.
 0194            var populatedParentIds = context.LinkedChildren
 0195                .Select(lc => lc.ParentId)
 0196                .Distinct()
 0197                .ToHashSet();
 198
 0199            var toInsert = linkedChildrenToAdd
 0200                .Where(lc => playlistParentIds.Contains(lc.ParentId)
 0201                    ? !populatedParentIds.Contains(lc.ParentId)
 0202                    : !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
 0203                .ToList();
 204
 0205            if (toInsert.Count > 0)
 206            {
 207                // Every container type other than a playlist keeps a single entry per child.
 208                // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
 0209                toInsert =
 0210                [
 0211                    .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)),
 0212                    .. toInsert
 0213                        .Where(lc => !playlistParentIds.Contains(lc.ParentId))
 0214                        .OrderBy(lc => lc.ChildType switch
 0215                        {
 0216                            LinkedChildType.LocalAlternateVersion => 0,
 0217                            LinkedChildType.LinkedAlternateVersion => 1,
 0218                            _ => 2
 0219                        })
 0220                        .DistinctBy(lc => new { lc.ParentId, lc.ChildId })
 0221                ];
 222
 0223                var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
 0224                var existingChildIds = context.BaseItems
 0225                    .WhereOneOrMany(childIds, b => b.Id)
 0226                    .Select(b => b.Id)
 0227                    .ToHashSet();
 228
 0229                toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
 230
 231                // Drop linked (user-merged) entries that point at items the parent owns (local
 232                // file-based alternates or extras). These stem from legacy data that merged an
 233                // owned item onto its own primary and would wrongly mark server-merged groups
 234                // as user-merged (splittable).
 0235                var linkedChildIds = toInsert
 0236                    .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion)
 0237                    .Select(lc => lc.ChildId)
 0238                    .Distinct()
 0239                    .ToList();
 240
 0241                if (linkedChildIds.Count > 0)
 242                {
 0243                    var ownerIdByChildId = context.BaseItems
 0244                        .WhereOneOrMany(linkedChildIds, b => b.Id)
 0245                        .Where(b => b.OwnerId.HasValue)
 0246                        .Select(b => new { b.Id, b.OwnerId })
 0247                        .ToDictionary(b => b.Id, b => b.OwnerId!.Value);
 248
 0249                    var removedCount = toInsert.RemoveAll(lc =>
 0250                        lc.ChildType == LinkedChildType.LinkedAlternateVersion
 0251                        && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId)
 0252                        && ownerId.Equals(lc.ParentId));
 253
 0254                    if (removedCount > 0)
 255                    {
 0256                        _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned b
 257                    }
 258                }
 259
 0260                context.LinkedChildren.AddRange(toInsert);
 0261                context.SaveChanges();
 262
 0263                _logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count);
 264            }
 265            else
 266            {
 0267                _logger.LogInformation("All LinkedChildren records already exist, nothing to insert.");
 268            }
 269        }
 270        else
 271        {
 6272            _logger.LogInformation("No LinkedChildren data found to migrate.");
 273        }
 274
 6275        _logger.LogInformation(
 6276            "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children."
 6277            processedCount,
 6278            droppedChildren);
 279
 6280        CleanupWrongTypeAlternateVersions(context);
 6281        CleanupOrphanedAlternateVersionBaseItems(context);
 6282        CleanupItemsFromDeletedLibraries(context);
 6283        CleanupStaleFileEntries(context);
 6284        CleanupOrphanedLinkedChildren(context);
 12285    }
 286
 287    private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context)
 288    {
 6289        _logger.LogInformation("Cleaning up alternate version items with wrong type...");
 290
 291        // Find all LocalAlternateVersion relationships where the child is a generic Video
 292        // but the parent is a more specific type (like Movie).
 293        // Since IDs are computed from type + path, just updating the Type column would break ID lookups.
 294        // Instead, delete them and let the runtime recreate them with the correct type during the next library scan.
 6295        var wrongTypeChildIds = context.LinkedChildren
 6296            .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion)
 6297            .Join(
 6298                context.BaseItems,
 6299                lc => lc.ParentId,
 6300                parent => parent.Id,
 6301                (lc, parent) => new { lc.ChildId, ParentType = parent.Type })
 6302            .Join(
 6303                context.BaseItems,
 6304                x => x.ChildId,
 6305                child => child.Id,
 6306                (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type })
 6307            .Where(x => x.ChildType != x.ParentType)
 6308            .Select(x => x.ChildId)
 6309            .Distinct()
 6310            .ToList();
 311
 6312        if (wrongTypeChildIds.Count == 0)
 313        {
 6314            _logger.LogInformation("No wrong-type alternate version items found.");
 6315            return;
 316        }
 317
 0318        _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
 319
 0320        var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items");
 321
 0322        _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the corr
 0323    }
 324
 325    private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
 326    {
 6327        _logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems...");
 328
 329        // Find BaseItems that have OwnerId set (they belonged to another item) and are not extras,
 330        // but no LinkedChild entry references them — meaning they're orphaned alternate versions.
 331        // This happens when a version file is renamed: the old BaseItem remains in the DB
 332        // with a stale OwnerId but nothing links to it anymore.
 6333        var orphanedVersionIds = context.BaseItems
 6334            .Where(b => b.OwnerId.HasValue && b.ExtraType == null)
 6335            .Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id)))
 6336            .Select(b => b.Id)
 6337            .ToList();
 338
 6339        if (orphanedVersionIds.Count == 0)
 340        {
 6341            _logger.LogInformation("No orphaned alternate version BaseItems found.");
 6342            return;
 343        }
 344
 0345        _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count
 346
 0347        var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems");
 348
 0349        _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
 0350    }
 351
 352    private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
 353    {
 6354        _logger.LogInformation("Starting cleanup of items from deleted libraries...");
 355
 356        // Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists.
 357        // This happens when a library is removed but the scan didn't fully clean up all items under it.
 6358        var orphanedIds = context.BaseItems
 6359            .Where(b => b.TopParentId.HasValue)
 6360            .Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value)))
 6361            .Select(b => b.Id)
 6362            .ToList();
 363
 6364        if (orphanedIds.Count == 0)
 365        {
 6366            _logger.LogInformation("No items from deleted libraries found.");
 6367            return;
 368        }
 369
 0370        _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
 371
 0372        var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries");
 373
 0374        _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
 0375    }
 376
 377    private void CleanupStaleFileEntries(JellyfinDbContext context)
 378    {
 6379        _logger.LogInformation("Starting cleanup of items with missing files...");
 380
 381        // Get all library media locations and partition into accessible vs inaccessible.
 382        // This mirrors the scanner's safeguard: if a library root is inaccessible
 383        // (e.g. NAS offline), we skip items under it to avoid false deletions.
 6384        var virtualFolders = _libraryManager.GetVirtualFolders();
 6385        var accessiblePaths = new List<string>();
 6386        var inaccessiblePaths = new List<string>();
 387
 22388        foreach (var folder in virtualFolders)
 389        {
 20390            foreach (var location in folder.Locations)
 391            {
 5392                if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any())
 393                {
 5394                    accessiblePaths.Add(location);
 395                }
 396                else
 397                {
 0398                    inaccessiblePaths.Add(location);
 0399                    _logger.LogWarning(
 0400                        "Library location {Path} is inaccessible or empty, skipping file existence checks for items unde
 0401                        location);
 402                }
 403            }
 404        }
 405
 6406        var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList();
 407
 408        // Get all non-folder, non-virtual items with paths from the DB
 6409        var itemsWithPaths = context.BaseItems
 6410            .Where(b => b.Path != null && b.Path != string.Empty)
 6411            .Where(b => !b.IsFolder && !b.IsVirtualItem)
 6412            .Select(b => new { b.Id, b.Path })
 6413            .ToList();
 414
 6415        var internalMetadataPath = _appPaths.InternalMetadataPath;
 416
 417        // An item outside every library location is normally left over from a removed media path, but
 418        // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the
 419        // first container start, for example). Only act on it while every location is readable.
 420        // Resolving no locations at all is the same hazard with none of the evidence: it cannot be
 421        // told apart from an install that has libraries the server has not read yet, and acting on it
 422        // means every file-backed item in the database is outside every location.
 6423        var canRemoveUnrootedItems = inaccessiblePaths.Count == 0 && allLibraryPaths.Count > 0;
 6424        var skippedUnrootedItems = 0;
 6425        var removedUnrootedItems = 0;
 426
 427        // One library losing its definition folder is the same accident confined to that library: it
 428        // contributes no locations, so everything it held reads as unrooted while the other libraries
 429        // still look healthy enough to act on.
 6430        var itemsInMissingLibraries = GetItemsInMissingLibraries(context, virtualFolders);
 6431        var skippedMissingLibraryItems = 0;
 432
 6433        var staleIds = new List<Guid>();
 6434        var checkedCount = 0;
 6435        _logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count);
 436
 24437        foreach (var item in itemsWithPaths)
 438        {
 439            // A miss on offline storage can block for the mount timeout, so report while scanning.
 6440            if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0)
 441            {
 0442                _logger.LogInformation(
 0443                    "Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.",
 0444                    checkedCount,
 0445                    itemsWithPaths.Count,
 0446                    staleIds.Count);
 447            }
 448
 6449            checkedCount++;
 450
 451            // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
 6452            var path = _appHost.ExpandVirtualPath(item.Path!);
 453
 454            // Skip items stored under internal metadata (images, subtitles, trickplay, etc.)
 6455            if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase))
 456            {
 457                continue;
 458            }
 459
 6460            if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
 461            {
 462                // Item is under an accessible library location — check if it still exists
 463                // Directory check covers BDMV/DVD items whose Path points to a folder
 2464                if (!File.Exists(path) && !Directory.Exists(path))
 465                {
 1466                    _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path);
 1467                    staleIds.Add(item.Id);
 468                }
 469            }
 4470            else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
 471            {
 472                // Item is not under ANY library location (accessible or not) —
 473                // it's orphaned from all libraries (e.g. media path was removed from config)
 4474                if (itemsInMissingLibraries.Contains(item.Id))
 475                {
 476                    // Its library is the thing that went missing, not its media path.
 1477                    skippedMissingLibraryItems++;
 478                }
 3479                else if (canRemoveUnrootedItems)
 480                {
 2481                    _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, 
 2482                    staleIds.Add(item.Id);
 2483                    removedUnrootedItems++;
 484                }
 485                else
 486                {
 1487                    skippedUnrootedItems++;
 488                }
 489            }
 490
 491            // Otherwise: item is under an inaccessible location — skip (storage may be offline)
 492        }
 493
 6494        if (skippedMissingLibraryItems > 0)
 495        {
 1496            _logger.LogWarning(
 1497                "Keeping {Count} items whose library definition is missing from {Path}. Restore the definition or recrea
 1498                skippedMissingLibraryItems,
 1499                _appPaths.DefaultUserViewsPath);
 500        }
 501
 6502        if (skippedUnrootedItems > 0)
 503        {
 1504            if (allLibraryPaths.Count == 0)
 505            {
 1506                _logger.LogWarning(
 1507                    "Keeping {Count} items that are outside every library location because this server resolved no libra
 1508                    skippedUnrootedItems);
 509            }
 510            else
 511            {
 0512                _logger.LogWarning(
 0513                    "Keeping {Count} items that are outside every library location because {LocationCount} library locat
 0514                    skippedUnrootedItems,
 0515                    inaccessiblePaths.Count);
 516            }
 517        }
 518
 6519        if (staleIds.Count == 0)
 520        {
 3521            _logger.LogInformation("No stale items found.");
 3522            return;
 523        }
 524
 525        // Whether an item went because its own file is gone or because nothing claims its path is the
 526        // difference between a handful of deletions and a whole library, so report the split.
 3527        _logger.LogInformation(
 3528            "Found {Count} stale items to remove, {UnrootedCount} of them because their path is outside every library lo
 3529            staleIds.Count,
 3530            removedUnrootedItems);
 531
 3532        var deleted = ResolveAndDeleteItems(staleIds, "items with missing files");
 533
 3534        _logger.LogInformation("Removed {Count} stale items.", deleted);
 3535    }
 536
 537    // Items under a library whose definition folder is gone. Their locations cannot be resolved, so
 538    // they read as unrooted, but the library is what went missing and the media path is still theirs.
 539    private HashSet<Guid> GetItemsInMissingLibraries(JellyfinDbContext context, IReadOnlyList<VirtualFolderInfo> virtual
 540    {
 6541        var liveLibraryPaths = virtualFolders
 6542            .Select(folder => Path.Combine(_appPaths.DefaultUserViewsPath, folder.Name))
 6543            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 544
 6545        var missingLibraryIds = context.BaseItems
 6546            .Where(b => b.Type == CollectionFolderTypeName && b.Path != null)
 6547            .Select(b => new { b.Id, b.Path })
 6548            .ToList()
 6549            .Where(b => !liveLibraryPaths.Contains(_appHost.ExpandVirtualPath(b.Path!)))
 6550            .Select(b => b.Id)
 6551            .ToList();
 552
 6553        if (missingLibraryIds.Count == 0)
 554        {
 5555            return [];
 556        }
 557
 1558        _logger.LogWarning(
 1559            "{Count} library definition(s) known to the database are not in {Path}.",
 1560            missingLibraryIds.Count,
 1561            _appPaths.DefaultUserViewsPath);
 562
 1563        return context.AncestorIds
 1564            .WhereOneOrMany(missingLibraryIds, a => a.ParentItemId)
 1565            .Select(a => a.ItemId)
 1566            .ToHashSet();
 567    }
 568
 569    private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description)
 570    {
 3571        if (ids.Count == 0)
 572        {
 0573            return 0;
 574        }
 575
 3576        return DeleteItems(ResolveItems(ids, description), description);
 577    }
 578
 579    private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description)
 580    {
 581        // Each lookup is a separate repository read; cached ones are fast, so this only reports
 582        // once a set is large enough for the reads to add up to a noticeable stretch.
 3583        var items = new List<BaseItem>(ids.Count);
 3584        var processed = 0;
 12585        foreach (var id in ids)
 586        {
 3587            if (processed > 0 && processed % ResolveProgressLogStep == 0)
 588            {
 0589                _logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.C
 590            }
 591
 3592            processed++;
 593
 3594            var item = _libraryManager.GetItemById(id);
 3595            if (item is not null)
 596            {
 3597                items.Add(item);
 598            }
 599        }
 600
 3601        return items;
 602    }
 603
 604    private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description)
 605    {
 3606        if (items.Count == 0)
 607        {
 0608            return 0;
 609        }
 610
 3611        var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
 3612        var deleted = 0;
 3613        var processed = 0;
 12614        foreach (var item in items)
 615        {
 3616            if (processed > 0 && processed % DeleteProgressLogStep == 0)
 617            {
 0618                _logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, item
 619            }
 620
 3621            processed++;
 622
 623            try
 624            {
 3625                _libraryManager.DeleteItem(item, options);
 3626                deleted++;
 3627            }
 0628            catch (Exception ex)
 629            {
 0630                _logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unk
 0631            }
 632        }
 633
 3634        return deleted;
 635    }
 636
 637    private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
 638    {
 6639        _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records...");
 640
 641        // Find all LinkedChildren where the ChildId doesn't exist in BaseItems
 6642        var orphanedLinkedChildren = context.LinkedChildren
 6643            .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId)))
 6644            .ToList();
 645
 6646        if (orphanedLinkedChildren.Count == 0)
 647        {
 6648            _logger.LogInformation("No orphaned LinkedChildren found.");
 6649            return;
 650        }
 651
 0652        _logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count)
 653
 0654        var orphanedByParent = context.LinkedChildren
 0655            .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId)))
 0656            .ToList();
 657
 0658        if (orphanedByParent.Count > 0)
 659        {
 0660            _logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count);
 0661            orphanedLinkedChildren.AddRange(orphanedByParent);
 662        }
 663
 664        // Remove all orphaned records. Both queries can return the same row, and a playlist may hold
 665        // several rows for one child, so the position is what identifies an entry here.
 0666        var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList();
 0667        context.LinkedChildren.RemoveRange(distinctOrphaned);
 0668        context.SaveChanges();
 669
 0670        _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
 0671    }
 672
 673    /// <summary>
 674    /// Resolves the item a legacy LinkedChild entry points at.
 675    /// </summary>
 676    private static Guid? ResolveChildId(
 677        JsonElement childElement,
 678        string? containingFolderPath,
 679        Dictionary<string, Guid> pathToIdMap,
 680        HashSet<Guid> allItemIds)
 681    {
 682        // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value
 683        // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore
 684        // fall through to the path, or the entry is lost even though its file is still in the library.
 0685        if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId))
 686        {
 0687            return itemId;
 688        }
 689
 0690        var path = GetStringProperty(childElement, "Path");
 0691        if (!string.IsNullOrEmpty(path))
 692        {
 0693            if (pathToIdMap.TryGetValue(path, out var idByPath))
 694            {
 0695                return idByPath;
 696            }
 697
 698            // 10.x resolved entries relative to the container that holds them.
 0699            if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath))
 700            {
 0701                string? absolutePath = null;
 702                try
 703                {
 0704                    absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path));
 0705                }
 0706                catch (ArgumentException)
 707                {
 708                    // Malformed path, nothing to resolve.
 0709                }
 710
 0711                if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath))
 712                {
 0713                    return idByAbsolutePath;
 714                }
 715            }
 716        }
 717
 0718        if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemI
 719        {
 0720            return libraryItemId;
 721        }
 722
 0723        return null;
 724    }
 725
 726    private static string? GetStringProperty(JsonElement element, string propertyName)
 0727        => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
 0728            ? property.GetString()
 0729            : null;
 730
 731    private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value)
 732    {
 0733        value = Guid.Empty;
 0734        var raw = GetStringProperty(element, propertyName);
 735
 0736        return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty();
 737    }
 738
 739    private void ProcessVideoAlternateVersions(
 740        JsonElement root,
 741        Guid parentId,
 742        Dictionary<string, Guid> pathToIdMap,
 743        HashSet<Guid> allItemIds,
 744        List<LinkedChildEntity> linkedChildrenToAdd)
 745    {
 0746        int sortOrder = 0;
 747
 0748        if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement)
 0749            && localAlternateVersionsElement.ValueKind == JsonValueKind.Array)
 750        {
 0751            foreach (var pathElement in localAlternateVersionsElement.EnumerateArray())
 752            {
 0753                if (pathElement.ValueKind != JsonValueKind.String)
 754                {
 755                    continue;
 756                }
 757
 0758                var path = pathElement.GetString();
 0759                if (string.IsNullOrEmpty(path))
 760                {
 761                    continue;
 762                }
 763
 764                // Try to resolve the path to an ItemId
 0765                if (pathToIdMap.TryGetValue(path, out var childId))
 766                {
 0767                    linkedChildrenToAdd.Add(new LinkedChildEntity
 0768                    {
 0769                        ParentId = parentId,
 0770                        ChildId = childId,
 0771                        ChildType = LinkedChildType.LocalAlternateVersion,
 0772                        SortOrder = sortOrder++
 0773                    });
 774
 0775                    _logger.LogDebug(
 0776                        "Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}",
 0777                        parentId,
 0778                        childId,
 0779                        path);
 780                }
 781                else
 782                {
 0783                    _logger.LogWarning(
 0784                        "Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}",
 0785                        path,
 0786                        parentId);
 787                }
 788            }
 789        }
 790
 0791        if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement)
 0792            && linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array)
 793        {
 0794            foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
 795            {
 0796                var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds);
 0797                if (!childId.HasValue)
 798                {
 0799                    _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parent
 0800                    continue;
 801                }
 802
 0803                linkedChildrenToAdd.Add(new LinkedChildEntity
 0804                {
 0805                    ParentId = parentId,
 0806                    ChildId = childId.Value,
 0807                    ChildType = LinkedChildType.LinkedAlternateVersion,
 0808                    SortOrder = sortOrder++
 0809                });
 810
 0811                _logger.LogDebug(
 0812                    "Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}",
 0813                    parentId,
 0814                    childId.Value);
 815            }
 816        }
 0817    }
 818}

Methods/Properties

.ctor(Microsoft.Extensions.Logging.ILoggerFactory,Microsoft.EntityFrameworkCore.IDbContextFactory`1<Jellyfin.Database.Implementations.JellyfinDbContext>,MediaBrowser.Controller.Library.ILibraryManager,MediaBrowser.Controller.IServerApplicationHost,MediaBrowser.Controller.IServerApplicationPaths)
Perform()
CleanupWrongTypeAlternateVersions(Jellyfin.Database.Implementations.JellyfinDbContext)
CleanupOrphanedAlternateVersionBaseItems(Jellyfin.Database.Implementations.JellyfinDbContext)
CleanupItemsFromDeletedLibraries(Jellyfin.Database.Implementations.JellyfinDbContext)
CleanupStaleFileEntries(Jellyfin.Database.Implementations.JellyfinDbContext)
GetItemsInMissingLibraries(Jellyfin.Database.Implementations.JellyfinDbContext,System.Collections.Generic.IReadOnlyList`1<MediaBrowser.Model.Entities.VirtualFolderInfo>)
ResolveAndDeleteItems(System.Collections.Generic.IReadOnlyCollection`1<System.Guid>,System.String)
ResolveItems(System.Collections.Generic.IReadOnlyCollection`1<System.Guid>,System.String)
DeleteItems(System.Collections.Generic.IReadOnlyCollection`1<MediaBrowser.Controller.Entities.BaseItem>,System.String)
CleanupOrphanedLinkedChildren(Jellyfin.Database.Implementations.JellyfinDbContext)
ResolveChildId(System.Text.Json.JsonElement,System.String,System.Collections.Generic.Dictionary`2<System.String,System.Guid>,System.Collections.Generic.HashSet`1<System.Guid>)
GetStringProperty(System.Text.Json.JsonElement,System.String)
TryGetGuidProperty(System.Text.Json.JsonElement,System.String,System.Guid&)
ProcessVideoAlternateVersions(System.Text.Json.JsonElement,System.Guid,System.Collections.Generic.Dictionary`2<System.String,System.Guid>,System.Collections.Generic.HashSet`1<System.Guid>,System.Collections.Generic.List`1<Jellyfin.Database.Implementations.Entities.LinkedChildEntity>)