< 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: 308
Coverable lines: 308
Total lines: 615
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 150
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: 615 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: 615

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 })
 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
 077        var linkedChildrenToAdd = new List<LinkedChildEntity>();
 078        var processedCount = 0;
 79        const int progressLogStep = 1000;
 080        var totalItems = itemsWithData.Count;
 81
 082        foreach (var item in itemsWithData)
 83        {
 084            if (string.IsNullOrEmpty(item.Data))
 85            {
 86                continue;
 87            }
 88
 089            if (processedCount > 0 && processedCount % progressLogStep == 0)
 90            {
 091                _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItem
 92            }
 93
 94            try
 95            {
 096                using var doc = JsonDocument.Parse(item.Data);
 97
 098                var isVideo = videoTypes.Contains(item.Type);
 99
 100                // Handle Video alternate versions
 0101                if (isVideo)
 102                {
 0103                    ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd);
 104                }
 105
 106                // Handle LinkedChildren (for containers and other items)
 0107                if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenEl
 108                {
 0109                    processedCount++;
 0110                    continue;
 111                }
 112
 0113                var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
 0114                var sortOrder = 0;
 0115                foreach (var childElement in linkedChildrenElement.EnumerateArray())
 116                {
 0117                    Guid? childId = null;
 0118                    if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKi
 119                    {
 0120                        var itemIdStr = itemIdProp.GetString();
 0121                        if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
 122                        {
 0123                            childId = parsedId;
 124                        }
 125                    }
 126
 0127                    if (!childId.HasValue || childId.Value.IsEmpty())
 128                    {
 0129                        if (childElement.TryGetProperty("Path", out var pathProp))
 130                        {
 0131                            var path = pathProp.GetString();
 0132                            if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
 133                            {
 0134                                childId = resolvedId;
 135                            }
 136                        }
 137                    }
 138
 0139                    if (!childId.HasValue || childId.Value.IsEmpty())
 140                    {
 0141                        if (childElement.TryGetProperty("LibraryItemId", out var libIdProp))
 142                        {
 0143                            var libIdStr = libIdProp.GetString();
 0144                            if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
 145                            {
 0146                                childId = parsedLibId;
 147                            }
 148                        }
 149                    }
 150
 0151                    if (!childId.HasValue || childId.Value.IsEmpty())
 152                    {
 153                        continue;
 154                    }
 155
 0156                    var childType = LinkedChildType.Manual;
 0157                    if (childElement.TryGetProperty("Type", out var typeProp))
 158                    {
 0159                        if (typeProp.ValueKind == JsonValueKind.Number)
 160                        {
 0161                            childType = (LinkedChildType)typeProp.GetInt32();
 162                        }
 0163                        else if (typeProp.ValueKind == JsonValueKind.String)
 164                        {
 0165                            var typeStr = typeProp.GetString();
 0166                            if (Enum.TryParse<LinkedChildType>(typeStr, out var parsedType))
 167                            {
 0168                                childType = parsedType;
 169                            }
 170                        }
 171                    }
 172
 0173                    linkedChildrenToAdd.Add(new LinkedChildEntity
 0174                    {
 0175                        ParentId = item.Id,
 0176                        ChildId = childId.Value,
 0177                        ChildType = childType,
 0178                        SortOrder = isPlaylist ? sortOrder : null
 0179                    });
 180
 0181                    sortOrder++;
 182                }
 183
 0184                processedCount++;
 0185            }
 0186            catch (JsonException ex)
 187            {
 0188                _logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id);
 0189            }
 190        }
 191
 0192        if (linkedChildrenToAdd.Count > 0)
 193        {
 0194            _logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count);
 195
 0196            var existingKeys = context.LinkedChildren
 0197                .Select(lc => new { lc.ParentId, lc.ChildId })
 0198                .ToHashSet();
 199
 0200            var toInsert = linkedChildrenToAdd
 0201                .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
 0202                .ToList();
 203
 0204            if (toInsert.Count > 0)
 205            {
 206                // Deduplicate by composite key (ParentId, ChildId)
 207                // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
 0208                toInsert = toInsert
 0209                    .OrderBy(lc => lc.ChildType switch
 0210                    {
 0211                        LinkedChildType.LocalAlternateVersion => 0,
 0212                        LinkedChildType.LinkedAlternateVersion => 1,
 0213                        _ => 2
 0214                    })
 0215                    .DistinctBy(lc => new { lc.ParentId, lc.ChildId })
 0216                    .ToList();
 217
 0218                var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
 0219                var existingChildIds = context.BaseItems
 0220                    .WhereOneOrMany(childIds, b => b.Id)
 0221                    .Select(b => b.Id)
 0222                    .ToHashSet();
 223
 0224                toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
 225
 0226                context.LinkedChildren.AddRange(toInsert);
 0227                context.SaveChanges();
 228
 0229                _logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count);
 230            }
 231            else
 232            {
 0233                _logger.LogInformation("All LinkedChildren records already exist, nothing to insert.");
 234            }
 235        }
 236        else
 237        {
 0238            _logger.LogInformation("No LinkedChildren data found to migrate.");
 239        }
 240
 0241        _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
 242
 0243        CleanupWrongTypeAlternateVersions(context);
 0244        CleanupOrphanedAlternateVersionBaseItems(context);
 0245        CleanupItemsFromDeletedLibraries(context);
 0246        CleanupStaleFileEntries(context);
 0247        CleanupOrphanedLinkedChildren(context);
 0248    }
 249
 250    private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context)
 251    {
 0252        _logger.LogInformation("Cleaning up alternate version items with wrong type...");
 253
 254        // Find all LocalAlternateVersion relationships where the child is a generic Video
 255        // but the parent is a more specific type (like Movie).
 256        // Since IDs are computed from type + path, just updating the Type column would break ID lookups.
 257        // Instead, delete them and let the runtime recreate them with the correct type during the next library scan.
 0258        var wrongTypeChildIds = context.LinkedChildren
 0259            .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion)
 0260            .Join(
 0261                context.BaseItems,
 0262                lc => lc.ParentId,
 0263                parent => parent.Id,
 0264                (lc, parent) => new { lc.ChildId, ParentType = parent.Type })
 0265            .Join(
 0266                context.BaseItems,
 0267                x => x.ChildId,
 0268                child => child.Id,
 0269                (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type })
 0270            .Where(x => x.ChildType != x.ParentType)
 0271            .Select(x => x.ChildId)
 0272            .Distinct()
 0273            .ToList();
 274
 0275        if (wrongTypeChildIds.Count == 0)
 276        {
 0277            _logger.LogInformation("No wrong-type alternate version items found.");
 0278            return;
 279        }
 280
 0281        _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
 282
 0283        var itemsToDelete = wrongTypeChildIds
 0284            .Select(id => _libraryManager.GetItemById(id))
 0285            .Where(item => item is not null)
 0286            .ToList();
 0287        var deleted = DeleteItems(itemsToDelete!);
 288
 0289        _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the corr
 0290    }
 291
 292    private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
 293    {
 0294        _logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems...");
 295
 296        // Find BaseItems that have OwnerId set (they belonged to another item) and are not extras,
 297        // but no LinkedChild entry references them — meaning they're orphaned alternate versions.
 298        // This happens when a version file is renamed: the old BaseItem remains in the DB
 299        // with a stale OwnerId but nothing links to it anymore.
 0300        var orphanedVersionIds = context.BaseItems
 0301            .Where(b => b.OwnerId.HasValue && b.ExtraType == null)
 0302            .Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id)))
 0303            .Select(b => b.Id)
 0304            .ToList();
 305
 0306        if (orphanedVersionIds.Count == 0)
 307        {
 0308            _logger.LogInformation("No orphaned alternate version BaseItems found.");
 0309            return;
 310        }
 311
 0312        _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count
 313
 0314        var itemsToDelete = orphanedVersionIds
 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} orphaned alternate version BaseItems.", deleted);
 0321    }
 322
 323    private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
 324    {
 0325        _logger.LogInformation("Starting cleanup of items from deleted libraries...");
 326
 327        // Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists.
 328        // This happens when a library is removed but the scan didn't fully clean up all items under it.
 0329        var orphanedIds = context.BaseItems
 0330            .Where(b => b.TopParentId.HasValue)
 0331            .Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value)))
 0332            .Select(b => b.Id)
 0333            .ToList();
 334
 0335        if (orphanedIds.Count == 0)
 336        {
 0337            _logger.LogInformation("No items from deleted libraries found.");
 0338            return;
 339        }
 340
 0341        _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
 342
 0343        var itemsToDelete = orphanedIds
 0344            .Select(id => _libraryManager.GetItemById(id))
 0345            .Where(item => item is not null)
 0346            .ToList();
 0347        var deleted = DeleteItems(itemsToDelete!);
 348
 0349        _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
 0350    }
 351
 352    private void CleanupStaleFileEntries(JellyfinDbContext context)
 353    {
 0354        _logger.LogInformation("Starting cleanup of items with missing files...");
 355
 356        // Get all library media locations and partition into accessible vs inaccessible.
 357        // This mirrors the scanner's safeguard: if a library root is inaccessible
 358        // (e.g. NAS offline), we skip items under it to avoid false deletions.
 0359        var virtualFolders = _libraryManager.GetVirtualFolders();
 0360        var accessiblePaths = new List<string>();
 0361        var inaccessiblePaths = new List<string>();
 362
 0363        foreach (var folder in virtualFolders)
 364        {
 0365            foreach (var location in folder.Locations)
 366            {
 0367                if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any())
 368                {
 0369                    accessiblePaths.Add(location);
 370                }
 371                else
 372                {
 0373                    inaccessiblePaths.Add(location);
 0374                    _logger.LogWarning(
 0375                        "Library location {Path} is inaccessible or empty, skipping file existence checks for items unde
 0376                        location);
 377                }
 378            }
 379        }
 380
 0381        var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList();
 382
 383        // Get all non-folder, non-virtual items with paths from the DB
 0384        var itemsWithPaths = context.BaseItems
 0385            .Where(b => b.Path != null && b.Path != string.Empty)
 0386            .Where(b => !b.IsFolder && !b.IsVirtualItem)
 0387            .Select(b => new { b.Id, b.Path })
 0388            .ToList();
 389
 0390        var internalMetadataPath = _appPaths.InternalMetadataPath;
 391
 0392        var staleIds = new List<Guid>();
 0393        foreach (var item in itemsWithPaths)
 394        {
 395            // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
 0396            var path = _appHost.ExpandVirtualPath(item.Path!);
 397
 398            // Skip items stored under internal metadata (images, subtitles, trickplay, etc.)
 0399            if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase))
 400            {
 401                continue;
 402            }
 403
 0404            if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
 405            {
 406                // Item is under an accessible library location — check if it still exists
 407                // Directory check covers BDMV/DVD items whose Path points to a folder
 0408                if (!File.Exists(path) && !Directory.Exists(path))
 409                {
 0410                    staleIds.Add(item.Id);
 411                }
 412            }
 0413            else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
 414            {
 415                // Item is not under ANY library location (accessible or not) —
 416                // it's orphaned from all libraries (e.g. media path was removed from config)
 0417                staleIds.Add(item.Id);
 418            }
 419
 420            // Otherwise: item is under an inaccessible location — skip (storage may be offline)
 421        }
 422
 0423        if (staleIds.Count == 0)
 424        {
 0425            _logger.LogInformation("No stale items found.");
 0426            return;
 427        }
 428
 0429        _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
 430
 0431        var itemsToDelete = staleIds
 0432            .Select(id => _libraryManager.GetItemById(id))
 0433            .Where(item => item is not null)
 0434            .ToList();
 0435        var deleted = DeleteItems(itemsToDelete!);
 436
 0437        _logger.LogInformation("Removed {Count} stale items.", deleted);
 0438    }
 439
 440    private int DeleteItems(IReadOnlyCollection<BaseItem> items)
 441    {
 0442        if (items.Count == 0)
 443        {
 0444            return 0;
 445        }
 446
 0447        var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
 0448        var deleted = 0;
 0449        foreach (var item in items)
 450        {
 451            try
 452            {
 0453                _libraryManager.DeleteItem(item, options);
 0454                deleted++;
 0455            }
 0456            catch (Exception ex)
 457            {
 0458                _logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unk
 0459            }
 460        }
 461
 0462        return deleted;
 463    }
 464
 465    private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
 466    {
 0467        _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records...");
 468
 469        // Find all LinkedChildren where the ChildId doesn't exist in BaseItems
 0470        var orphanedLinkedChildren = context.LinkedChildren
 0471            .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId)))
 0472            .ToList();
 473
 0474        if (orphanedLinkedChildren.Count == 0)
 475        {
 0476            _logger.LogInformation("No orphaned LinkedChildren found.");
 0477            return;
 478        }
 479
 0480        _logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count)
 481
 0482        var orphanedByParent = context.LinkedChildren
 0483            .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId)))
 0484            .ToList();
 485
 0486        if (orphanedByParent.Count > 0)
 487        {
 0488            _logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count);
 0489            orphanedLinkedChildren.AddRange(orphanedByParent);
 490        }
 491
 492        // Remove all orphaned records
 0493        var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList();
 0494        context.LinkedChildren.RemoveRange(distinctOrphaned);
 0495        context.SaveChanges();
 496
 0497        _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
 0498    }
 499
 500    private void ProcessVideoAlternateVersions(
 501        JsonElement root,
 502        Guid parentId,
 503        Dictionary<string, Guid> pathToIdMap,
 504        List<LinkedChildEntity> linkedChildrenToAdd)
 505    {
 0506        int sortOrder = 0;
 507
 0508        if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement)
 0509            && localAlternateVersionsElement.ValueKind == JsonValueKind.Array)
 510        {
 0511            foreach (var pathElement in localAlternateVersionsElement.EnumerateArray())
 512            {
 0513                if (pathElement.ValueKind != JsonValueKind.String)
 514                {
 515                    continue;
 516                }
 517
 0518                var path = pathElement.GetString();
 0519                if (string.IsNullOrEmpty(path))
 520                {
 521                    continue;
 522                }
 523
 524                // Try to resolve the path to an ItemId
 0525                if (pathToIdMap.TryGetValue(path, out var childId))
 526                {
 0527                    linkedChildrenToAdd.Add(new LinkedChildEntity
 0528                    {
 0529                        ParentId = parentId,
 0530                        ChildId = childId,
 0531                        ChildType = LinkedChildType.LocalAlternateVersion,
 0532                        SortOrder = sortOrder++
 0533                    });
 534
 0535                    _logger.LogDebug(
 0536                        "Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}",
 0537                        parentId,
 0538                        childId,
 0539                        path);
 540                }
 541                else
 542                {
 0543                    _logger.LogWarning(
 0544                        "Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}",
 0545                        path,
 0546                        parentId);
 547                }
 548            }
 549        }
 550
 0551        if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement)
 0552            && linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array)
 553        {
 0554            foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
 555            {
 0556                Guid? childId = null;
 557
 558                // Try to get ItemId
 0559                if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValue
 560                {
 0561                    var itemIdStr = itemIdProp.GetString();
 0562                    if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
 563                    {
 0564                        childId = parsedId;
 565                    }
 566                }
 567
 568                // Try to get from Path if ItemId not available
 0569                if (!childId.HasValue || childId.Value.IsEmpty())
 570                {
 0571                    if (linkedChildElement.TryGetProperty("Path", out var pathProp))
 572                    {
 0573                        var path = pathProp.GetString();
 0574                        if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
 575                        {
 0576                            childId = resolvedId;
 577                        }
 578                    }
 579                }
 580
 581                // Try LibraryItemId as fallback
 0582                if (!childId.HasValue || childId.Value.IsEmpty())
 583                {
 0584                    if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp))
 585                    {
 0586                        var libIdStr = libIdProp.GetString();
 0587                        if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
 588                        {
 0589                            childId = parsedLibId;
 590                        }
 591                    }
 592                }
 593
 0594                if (!childId.HasValue || childId.Value.IsEmpty())
 595                {
 0596                    _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parent
 0597                    continue;
 598                }
 599
 0600                linkedChildrenToAdd.Add(new LinkedChildEntity
 0601                {
 0602                    ParentId = parentId,
 0603                    ChildId = childId.Value,
 0604                    ChildType = LinkedChildType.LinkedAlternateVersion,
 0605                    SortOrder = sortOrder++
 0606                });
 607
 0608                _logger.LogDebug(
 0609                    "Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}",
 0610                    parentId,
 0611                    childId.Value);
 612            }
 613        }
 0614    }
 615}