< 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
0%
Covered lines: 0
Uncovered lines: 358
Coverable lines: 358
Total lines: 700
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 132
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/296) Branch coverage: 0% (0/144) Total lines: 5895/13/2026 - 12:15:27 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: 700 5/4/2026 - 12:15:16 AM Line coverage: 0% (0/296) Branch coverage: 0% (0/144) Total lines: 5895/13/2026 - 12:15:27 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: 700

Coverage delta

Coverage delta 1 -1

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 Microsoft.EntityFrameworkCore;
 13using Microsoft.Extensions.Logging;
 14using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
 15
 16namespace Jellyfin.Server.Migrations.Routines;
 17
 18/// <summary>
 19/// Migrates LinkedChildren data from JSON Data column to the LinkedChildren table.
 20/// </summary>
 21[JellyfinMigration("2026-01-13T12:00:00", nameof(MigrateLinkedChildren))]
 22[JellyfinMigrationBackup(JellyfinDb = true)]
 23internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
 24{
 25    private readonly ILogger<MigrateLinkedChildren> _logger;
 26    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 27    private readonly ILibraryManager _libraryManager;
 28    private readonly IServerApplicationHost _appHost;
 29    private readonly IServerApplicationPaths _appPaths;
 30
 31    public MigrateLinkedChildren(
 32        ILoggerFactory loggerFactory,
 33        IDbContextFactory<JellyfinDbContext> dbProvider,
 34        ILibraryManager libraryManager,
 35        IServerApplicationHost appHost,
 36        IServerApplicationPaths appPaths)
 37    {
 038        _logger = loggerFactory.CreateLogger<MigrateLinkedChildren>();
 039        _dbProvider = dbProvider;
 040        _libraryManager = libraryManager;
 041        _appHost = appHost;
 042        _appPaths = appPaths;
 043    }
 44
 45    /// <inheritdoc/>
 46    public void Perform()
 47    {
 048        using var context = _dbProvider.CreateDbContext();
 49
 050        var containerTypes = new[]
 051        {
 052            "MediaBrowser.Controller.Entities.Movies.BoxSet",
 053            "MediaBrowser.Controller.Playlists.Playlist",
 054            "MediaBrowser.Controller.Entities.CollectionFolder"
 055        };
 56
 057        var videoTypes = new[]
 058        {
 059            "MediaBrowser.Controller.Entities.Video",
 060            "MediaBrowser.Controller.Entities.Movies.Movie",
 061            "MediaBrowser.Controller.Entities.TV.Episode"
 062        };
 63
 064        var itemsWithData = context.BaseItems
 065            .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
 066            .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder })
 067            .ToList();
 68
 069        _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count
 70
 071        var pathToIdMap = context.BaseItems
 072            .Where(b => b.Path != null)
 073            .Select(b => new { b.Id, b.Path })
 074            .GroupBy(b => b.Path!)
 075            .ToDictionary(g => g.Key, g => g.First().Id);
 76
 77        // Needed to tell a stale cached ItemId apart from one that still points at a real item.
 078        var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet();
 79
 080        var playlistParentIds = itemsWithData
 081            .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist")
 082            .Select(b => b.Id)
 083            .ToHashSet();
 84
 085        var droppedChildren = 0;
 086        var linkedChildrenToAdd = new List<LinkedChildEntity>();
 087        var processedCount = 0;
 88        const int progressLogStep = 1000;
 089        var totalItems = itemsWithData.Count;
 90
 091        foreach (var item in itemsWithData)
 92        {
 093            if (string.IsNullOrEmpty(item.Data))
 94            {
 95                continue;
 96            }
 97
 098            if (processedCount > 0 && processedCount % progressLogStep == 0)
 99            {
 0100                _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItem
 101            }
 102
 103            try
 104            {
 0105                using var doc = JsonDocument.Parse(item.Data);
 106
 0107                var isVideo = videoTypes.Contains(item.Type);
 108
 109                // Handle Video alternate versions
 0110                if (isVideo)
 111                {
 0112                    ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd
 113                }
 114
 115                // Handle LinkedChildren (for containers and other items)
 0116                if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenEl
 117                {
 0118                    processedCount++;
 0119                    continue;
 120                }
 121
 122                // Legacy entries may hold a path relative to the container that holds them, so the
 123                // container's own location has to be a real path, not a virtual one.
 0124                var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path);
 0125                var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath);
 0126                var sortOrder = 0;
 0127                foreach (var childElement in linkedChildrenElement.EnumerateArray())
 128                {
 0129                    var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds);
 0130                    if (!childId.HasValue)
 131                    {
 0132                        droppedChildren++;
 0133                        _logger.LogWarning(
 0134                            "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}",
 0135                            item.Id,
 0136                            GetStringProperty(childElement, "ItemId") ?? "none",
 0137                            GetStringProperty(childElement, "Path") ?? "none");
 0138                        continue;
 139                    }
 140
 0141                    var childType = LinkedChildType.Manual;
 0142                    if (childElement.TryGetProperty("Type", out var typeProp))
 143                    {
 0144                        if (typeProp.ValueKind == JsonValueKind.Number)
 145                        {
 0146                            childType = (LinkedChildType)typeProp.GetInt32();
 147                        }
 0148                        else if (typeProp.ValueKind == JsonValueKind.String)
 149                        {
 0150                            var typeStr = typeProp.GetString();
 0151                            if (Enum.TryParse<LinkedChildType>(typeStr, out var parsedType))
 152                            {
 0153                                childType = parsedType;
 154                            }
 155                        }
 156                    }
 157
 0158                    linkedChildrenToAdd.Add(new LinkedChildEntity
 0159                    {
 0160                        ParentId = item.Id,
 0161                        ChildId = childId.Value,
 0162                        ChildType = childType,
 0163                        SortOrder = sortOrder
 0164                    });
 165
 0166                    sortOrder++;
 167                }
 168
 0169                processedCount++;
 0170            }
 0171            catch (JsonException ex)
 172            {
 0173                _logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id);
 0174            }
 175        }
 176
 0177        if (linkedChildrenToAdd.Count > 0)
 178        {
 0179            _logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count);
 180
 0181            var existingKeys = context.LinkedChildren
 0182                .Select(lc => new { lc.ParentId, lc.ChildId })
 0183                .ToHashSet();
 184
 185            // A playlist may list the same child more than once, so it cannot be keyed by
 186            // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which
 187            // keeps the routine re-runnable without collapsing repeated entries.
 0188            var populatedParentIds = context.LinkedChildren
 0189                .Select(lc => lc.ParentId)
 0190                .Distinct()
 0191                .ToHashSet();
 192
 0193            var toInsert = linkedChildrenToAdd
 0194                .Where(lc => playlistParentIds.Contains(lc.ParentId)
 0195                    ? !populatedParentIds.Contains(lc.ParentId)
 0196                    : !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
 0197                .ToList();
 198
 0199            if (toInsert.Count > 0)
 200            {
 201                // Every container type other than a playlist keeps a single entry per child.
 202                // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
 0203                toInsert =
 0204                [
 0205                    .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)),
 0206                    .. toInsert
 0207                        .Where(lc => !playlistParentIds.Contains(lc.ParentId))
 0208                        .OrderBy(lc => lc.ChildType switch
 0209                        {
 0210                            LinkedChildType.LocalAlternateVersion => 0,
 0211                            LinkedChildType.LinkedAlternateVersion => 1,
 0212                            _ => 2
 0213                        })
 0214                        .DistinctBy(lc => new { lc.ParentId, lc.ChildId })
 0215                ];
 216
 0217                var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
 0218                var existingChildIds = context.BaseItems
 0219                    .WhereOneOrMany(childIds, b => b.Id)
 0220                    .Select(b => b.Id)
 0221                    .ToHashSet();
 222
 0223                toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
 224
 225                // Drop linked (user-merged) entries that point at items the parent owns (local
 226                // file-based alternates or extras). These stem from legacy data that merged an
 227                // owned item onto its own primary and would wrongly mark server-merged groups
 228                // as user-merged (splittable).
 0229                var linkedChildIds = toInsert
 0230                    .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion)
 0231                    .Select(lc => lc.ChildId)
 0232                    .Distinct()
 0233                    .ToList();
 234
 0235                if (linkedChildIds.Count > 0)
 236                {
 0237                    var ownerIdByChildId = context.BaseItems
 0238                        .WhereOneOrMany(linkedChildIds, b => b.Id)
 0239                        .Where(b => b.OwnerId.HasValue)
 0240                        .Select(b => new { b.Id, b.OwnerId })
 0241                        .ToDictionary(b => b.Id, b => b.OwnerId!.Value);
 242
 0243                    var removedCount = toInsert.RemoveAll(lc =>
 0244                        lc.ChildType == LinkedChildType.LinkedAlternateVersion
 0245                        && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId)
 0246                        && ownerId.Equals(lc.ParentId));
 247
 0248                    if (removedCount > 0)
 249                    {
 0250                        _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned b
 251                    }
 252                }
 253
 0254                context.LinkedChildren.AddRange(toInsert);
 0255                context.SaveChanges();
 256
 0257                _logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count);
 258            }
 259            else
 260            {
 0261                _logger.LogInformation("All LinkedChildren records already exist, nothing to insert.");
 262            }
 263        }
 264        else
 265        {
 0266            _logger.LogInformation("No LinkedChildren data found to migrate.");
 267        }
 268
 0269        _logger.LogInformation(
 0270            "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children."
 0271            processedCount,
 0272            droppedChildren);
 273
 0274        CleanupWrongTypeAlternateVersions(context);
 0275        CleanupOrphanedAlternateVersionBaseItems(context);
 0276        CleanupItemsFromDeletedLibraries(context);
 0277        CleanupStaleFileEntries(context);
 0278        CleanupOrphanedLinkedChildren(context);
 0279    }
 280
 281    private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context)
 282    {
 0283        _logger.LogInformation("Cleaning up alternate version items with wrong type...");
 284
 285        // Find all LocalAlternateVersion relationships where the child is a generic Video
 286        // but the parent is a more specific type (like Movie).
 287        // Since IDs are computed from type + path, just updating the Type column would break ID lookups.
 288        // Instead, delete them and let the runtime recreate them with the correct type during the next library scan.
 0289        var wrongTypeChildIds = context.LinkedChildren
 0290            .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion)
 0291            .Join(
 0292                context.BaseItems,
 0293                lc => lc.ParentId,
 0294                parent => parent.Id,
 0295                (lc, parent) => new { lc.ChildId, ParentType = parent.Type })
 0296            .Join(
 0297                context.BaseItems,
 0298                x => x.ChildId,
 0299                child => child.Id,
 0300                (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type })
 0301            .Where(x => x.ChildType != x.ParentType)
 0302            .Select(x => x.ChildId)
 0303            .Distinct()
 0304            .ToList();
 305
 0306        if (wrongTypeChildIds.Count == 0)
 307        {
 0308            _logger.LogInformation("No wrong-type alternate version items found.");
 0309            return;
 310        }
 311
 0312        _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
 313
 0314        var itemsToDelete = wrongTypeChildIds
 0315            .Select(id => _libraryManager.GetItemById(id))
 0316            .Where(item => item is not null)
 0317            .ToList();
 0318        var deleted = DeleteItems(itemsToDelete!);
 319
 0320        _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the corr
 0321    }
 322
 323    private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
 324    {
 0325        _logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems...");
 326
 327        // Find BaseItems that have OwnerId set (they belonged to another item) and are not extras,
 328        // but no LinkedChild entry references them — meaning they're orphaned alternate versions.
 329        // This happens when a version file is renamed: the old BaseItem remains in the DB
 330        // with a stale OwnerId but nothing links to it anymore.
 0331        var orphanedVersionIds = context.BaseItems
 0332            .Where(b => b.OwnerId.HasValue && b.ExtraType == null)
 0333            .Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id)))
 0334            .Select(b => b.Id)
 0335            .ToList();
 336
 0337        if (orphanedVersionIds.Count == 0)
 338        {
 0339            _logger.LogInformation("No orphaned alternate version BaseItems found.");
 0340            return;
 341        }
 342
 0343        _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count
 344
 0345        var itemsToDelete = orphanedVersionIds
 0346            .Select(id => _libraryManager.GetItemById(id))
 0347            .Where(item => item is not null)
 0348            .ToList();
 0349        var deleted = DeleteItems(itemsToDelete!);
 350
 0351        _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
 0352    }
 353
 354    private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
 355    {
 0356        _logger.LogInformation("Starting cleanup of items from deleted libraries...");
 357
 358        // Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists.
 359        // This happens when a library is removed but the scan didn't fully clean up all items under it.
 0360        var orphanedIds = context.BaseItems
 0361            .Where(b => b.TopParentId.HasValue)
 0362            .Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value)))
 0363            .Select(b => b.Id)
 0364            .ToList();
 365
 0366        if (orphanedIds.Count == 0)
 367        {
 0368            _logger.LogInformation("No items from deleted libraries found.");
 0369            return;
 370        }
 371
 0372        _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
 373
 0374        var itemsToDelete = orphanedIds
 0375            .Select(id => _libraryManager.GetItemById(id))
 0376            .Where(item => item is not null)
 0377            .ToList();
 0378        var deleted = DeleteItems(itemsToDelete!);
 379
 0380        _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
 0381    }
 382
 383    private void CleanupStaleFileEntries(JellyfinDbContext context)
 384    {
 0385        _logger.LogInformation("Starting cleanup of items with missing files...");
 386
 387        // Get all library media locations and partition into accessible vs inaccessible.
 388        // This mirrors the scanner's safeguard: if a library root is inaccessible
 389        // (e.g. NAS offline), we skip items under it to avoid false deletions.
 0390        var virtualFolders = _libraryManager.GetVirtualFolders();
 0391        var accessiblePaths = new List<string>();
 0392        var inaccessiblePaths = new List<string>();
 393
 0394        foreach (var folder in virtualFolders)
 395        {
 0396            foreach (var location in folder.Locations)
 397            {
 0398                if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any())
 399                {
 0400                    accessiblePaths.Add(location);
 401                }
 402                else
 403                {
 0404                    inaccessiblePaths.Add(location);
 0405                    _logger.LogWarning(
 0406                        "Library location {Path} is inaccessible or empty, skipping file existence checks for items unde
 0407                        location);
 408                }
 409            }
 410        }
 411
 0412        var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList();
 413
 414        // Get all non-folder, non-virtual items with paths from the DB
 0415        var itemsWithPaths = context.BaseItems
 0416            .Where(b => b.Path != null && b.Path != string.Empty)
 0417            .Where(b => !b.IsFolder && !b.IsVirtualItem)
 0418            .Select(b => new { b.Id, b.Path })
 0419            .ToList();
 420
 0421        var internalMetadataPath = _appPaths.InternalMetadataPath;
 422
 423        // An item outside every library location is normally left over from a removed media path, but
 424        // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the
 425        // first container start, for example). Only act on it while every location is readable.
 0426        var canRemoveUnrootedItems = inaccessiblePaths.Count == 0;
 0427        var skippedUnrootedItems = 0;
 428
 0429        var staleIds = new List<Guid>();
 0430        foreach (var item in itemsWithPaths)
 431        {
 432            // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
 0433            var path = _appHost.ExpandVirtualPath(item.Path!);
 434
 435            // Skip items stored under internal metadata (images, subtitles, trickplay, etc.)
 0436            if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase))
 437            {
 438                continue;
 439            }
 440
 0441            if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
 442            {
 443                // Item is under an accessible library location — check if it still exists
 444                // Directory check covers BDMV/DVD items whose Path points to a folder
 0445                if (!File.Exists(path) && !Directory.Exists(path))
 446                {
 0447                    _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path);
 0448                    staleIds.Add(item.Id);
 449                }
 450            }
 0451            else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
 452            {
 453                // Item is not under ANY library location (accessible or not) —
 454                // it's orphaned from all libraries (e.g. media path was removed from config)
 0455                if (canRemoveUnrootedItems)
 456                {
 0457                    _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, 
 0458                    staleIds.Add(item.Id);
 459                }
 460                else
 461                {
 0462                    skippedUnrootedItems++;
 463                }
 464            }
 465
 466            // Otherwise: item is under an inaccessible location — skip (storage may be offline)
 467        }
 468
 0469        if (skippedUnrootedItems > 0)
 470        {
 0471            _logger.LogWarning(
 0472                "Keeping {Count} items that are outside every library location because {LocationCount} library location(
 0473                skippedUnrootedItems,
 0474                inaccessiblePaths.Count);
 475        }
 476
 0477        if (staleIds.Count == 0)
 478        {
 0479            _logger.LogInformation("No stale items found.");
 0480            return;
 481        }
 482
 0483        _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
 484
 0485        var itemsToDelete = staleIds
 0486            .Select(id => _libraryManager.GetItemById(id))
 0487            .Where(item => item is not null)
 0488            .ToList();
 0489        var deleted = DeleteItems(itemsToDelete!);
 490
 0491        _logger.LogInformation("Removed {Count} stale items.", deleted);
 0492    }
 493
 494    private int DeleteItems(IReadOnlyCollection<BaseItem> items)
 495    {
 0496        if (items.Count == 0)
 497        {
 0498            return 0;
 499        }
 500
 0501        var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
 0502        var deleted = 0;
 0503        foreach (var item in items)
 504        {
 505            try
 506            {
 0507                _libraryManager.DeleteItem(item, options);
 0508                deleted++;
 0509            }
 0510            catch (Exception ex)
 511            {
 0512                _logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unk
 0513            }
 514        }
 515
 0516        return deleted;
 517    }
 518
 519    private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
 520    {
 0521        _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records...");
 522
 523        // Find all LinkedChildren where the ChildId doesn't exist in BaseItems
 0524        var orphanedLinkedChildren = context.LinkedChildren
 0525            .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId)))
 0526            .ToList();
 527
 0528        if (orphanedLinkedChildren.Count == 0)
 529        {
 0530            _logger.LogInformation("No orphaned LinkedChildren found.");
 0531            return;
 532        }
 533
 0534        _logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count)
 535
 0536        var orphanedByParent = context.LinkedChildren
 0537            .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId)))
 0538            .ToList();
 539
 0540        if (orphanedByParent.Count > 0)
 541        {
 0542            _logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count);
 0543            orphanedLinkedChildren.AddRange(orphanedByParent);
 544        }
 545
 546        // Remove all orphaned records. Both queries can return the same row, and a playlist may hold
 547        // several rows for one child, so the position is what identifies an entry here.
 0548        var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList();
 0549        context.LinkedChildren.RemoveRange(distinctOrphaned);
 0550        context.SaveChanges();
 551
 0552        _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
 0553    }
 554
 555    /// <summary>
 556    /// Resolves the item a legacy LinkedChild entry points at.
 557    /// </summary>
 558    private static Guid? ResolveChildId(
 559        JsonElement childElement,
 560        string? containingFolderPath,
 561        Dictionary<string, Guid> pathToIdMap,
 562        HashSet<Guid> allItemIds)
 563    {
 564        // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value
 565        // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore
 566        // fall through to the path, or the entry is lost even though its file is still in the library.
 0567        if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId))
 568        {
 0569            return itemId;
 570        }
 571
 0572        var path = GetStringProperty(childElement, "Path");
 0573        if (!string.IsNullOrEmpty(path))
 574        {
 0575            if (pathToIdMap.TryGetValue(path, out var idByPath))
 576            {
 0577                return idByPath;
 578            }
 579
 580            // 10.x resolved entries relative to the container that holds them.
 0581            if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath))
 582            {
 0583                string? absolutePath = null;
 584                try
 585                {
 0586                    absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path));
 0587                }
 0588                catch (ArgumentException)
 589                {
 590                    // Malformed path, nothing to resolve.
 0591                }
 592
 0593                if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath))
 594                {
 0595                    return idByAbsolutePath;
 596                }
 597            }
 598        }
 599
 0600        if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemI
 601        {
 0602            return libraryItemId;
 603        }
 604
 0605        return null;
 606    }
 607
 608    private static string? GetStringProperty(JsonElement element, string propertyName)
 0609        => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
 0610            ? property.GetString()
 0611            : null;
 612
 613    private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value)
 614    {
 0615        value = Guid.Empty;
 0616        var raw = GetStringProperty(element, propertyName);
 617
 0618        return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty();
 619    }
 620
 621    private void ProcessVideoAlternateVersions(
 622        JsonElement root,
 623        Guid parentId,
 624        Dictionary<string, Guid> pathToIdMap,
 625        HashSet<Guid> allItemIds,
 626        List<LinkedChildEntity> linkedChildrenToAdd)
 627    {
 0628        int sortOrder = 0;
 629
 0630        if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement)
 0631            && localAlternateVersionsElement.ValueKind == JsonValueKind.Array)
 632        {
 0633            foreach (var pathElement in localAlternateVersionsElement.EnumerateArray())
 634            {
 0635                if (pathElement.ValueKind != JsonValueKind.String)
 636                {
 637                    continue;
 638                }
 639
 0640                var path = pathElement.GetString();
 0641                if (string.IsNullOrEmpty(path))
 642                {
 643                    continue;
 644                }
 645
 646                // Try to resolve the path to an ItemId
 0647                if (pathToIdMap.TryGetValue(path, out var childId))
 648                {
 0649                    linkedChildrenToAdd.Add(new LinkedChildEntity
 0650                    {
 0651                        ParentId = parentId,
 0652                        ChildId = childId,
 0653                        ChildType = LinkedChildType.LocalAlternateVersion,
 0654                        SortOrder = sortOrder++
 0655                    });
 656
 0657                    _logger.LogDebug(
 0658                        "Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}",
 0659                        parentId,
 0660                        childId,
 0661                        path);
 662                }
 663                else
 664                {
 0665                    _logger.LogWarning(
 0666                        "Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}",
 0667                        path,
 0668                        parentId);
 669                }
 670            }
 671        }
 672
 0673        if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement)
 0674            && linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array)
 675        {
 0676            foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
 677            {
 0678                var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds);
 0679                if (!childId.HasValue)
 680                {
 0681                    _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parent
 0682                    continue;
 683                }
 684
 0685                linkedChildrenToAdd.Add(new LinkedChildEntity
 0686                {
 0687                    ParentId = parentId,
 0688                    ChildId = childId.Value,
 0689                    ChildType = LinkedChildType.LinkedAlternateVersion,
 0690                    SortOrder = sortOrder++
 0691                });
 692
 0693                _logger.LogDebug(
 0694                    "Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}",
 0695                    parentId,
 0696                    childId.Value);
 697            }
 698        }
 0699    }
 700}