mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-13 08:37:07 +00:00
615 lines
24 KiB
C#
615 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using Jellyfin.Database.Implementations;
|
|
using Jellyfin.Database.Implementations.Entities;
|
|
using Jellyfin.Extensions;
|
|
using MediaBrowser.Controller;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
|
|
|
|
namespace Jellyfin.Server.Migrations.Routines;
|
|
|
|
/// <summary>
|
|
/// Migrates LinkedChildren data from JSON Data column to the LinkedChildren table.
|
|
/// </summary>
|
|
[JellyfinMigration("2026-01-13T12:00:00", nameof(MigrateLinkedChildren))]
|
|
[JellyfinMigrationBackup(JellyfinDb = true)]
|
|
internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
|
{
|
|
private readonly ILogger<MigrateLinkedChildren> _logger;
|
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly IServerApplicationHost _appHost;
|
|
private readonly IServerApplicationPaths _appPaths;
|
|
|
|
public MigrateLinkedChildren(
|
|
ILoggerFactory loggerFactory,
|
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
|
ILibraryManager libraryManager,
|
|
IServerApplicationHost appHost,
|
|
IServerApplicationPaths appPaths)
|
|
{
|
|
_logger = loggerFactory.CreateLogger<MigrateLinkedChildren>();
|
|
_dbProvider = dbProvider;
|
|
_libraryManager = libraryManager;
|
|
_appHost = appHost;
|
|
_appPaths = appPaths;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Perform()
|
|
{
|
|
using var context = _dbProvider.CreateDbContext();
|
|
|
|
var containerTypes = new[]
|
|
{
|
|
"MediaBrowser.Controller.Entities.Movies.BoxSet",
|
|
"MediaBrowser.Controller.Playlists.Playlist",
|
|
"MediaBrowser.Controller.Entities.CollectionFolder"
|
|
};
|
|
|
|
var videoTypes = new[]
|
|
{
|
|
"MediaBrowser.Controller.Entities.Video",
|
|
"MediaBrowser.Controller.Entities.Movies.Movie",
|
|
"MediaBrowser.Controller.Entities.TV.Episode"
|
|
};
|
|
|
|
var itemsWithData = context.BaseItems
|
|
.Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
|
|
.Select(b => new { b.Id, b.Data, b.Type })
|
|
.ToList();
|
|
|
|
_logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count);
|
|
|
|
var pathToIdMap = context.BaseItems
|
|
.Where(b => b.Path != null)
|
|
.Select(b => new { b.Id, b.Path })
|
|
.GroupBy(b => b.Path!)
|
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
|
|
|
var linkedChildrenToAdd = new List<LinkedChildEntity>();
|
|
var processedCount = 0;
|
|
const int progressLogStep = 1000;
|
|
var totalItems = itemsWithData.Count;
|
|
|
|
foreach (var item in itemsWithData)
|
|
{
|
|
if (string.IsNullOrEmpty(item.Data))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (processedCount > 0 && processedCount % progressLogStep == 0)
|
|
{
|
|
_logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems);
|
|
}
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(item.Data);
|
|
|
|
var isVideo = videoTypes.Contains(item.Type);
|
|
|
|
// Handle Video alternate versions
|
|
if (isVideo)
|
|
{
|
|
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd);
|
|
}
|
|
|
|
// Handle LinkedChildren (for containers and other items)
|
|
if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenElement.ValueKind != JsonValueKind.Array)
|
|
{
|
|
processedCount++;
|
|
continue;
|
|
}
|
|
|
|
var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
|
|
var sortOrder = 0;
|
|
foreach (var childElement in linkedChildrenElement.EnumerateArray())
|
|
{
|
|
Guid? childId = null;
|
|
if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
|
|
{
|
|
var itemIdStr = itemIdProp.GetString();
|
|
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
|
|
{
|
|
childId = parsedId;
|
|
}
|
|
}
|
|
|
|
if (!childId.HasValue || childId.Value.IsEmpty())
|
|
{
|
|
if (childElement.TryGetProperty("Path", out var pathProp))
|
|
{
|
|
var path = pathProp.GetString();
|
|
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
|
|
{
|
|
childId = resolvedId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!childId.HasValue || childId.Value.IsEmpty())
|
|
{
|
|
if (childElement.TryGetProperty("LibraryItemId", out var libIdProp))
|
|
{
|
|
var libIdStr = libIdProp.GetString();
|
|
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
|
|
{
|
|
childId = parsedLibId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!childId.HasValue || childId.Value.IsEmpty())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var childType = LinkedChildType.Manual;
|
|
if (childElement.TryGetProperty("Type", out var typeProp))
|
|
{
|
|
if (typeProp.ValueKind == JsonValueKind.Number)
|
|
{
|
|
childType = (LinkedChildType)typeProp.GetInt32();
|
|
}
|
|
else if (typeProp.ValueKind == JsonValueKind.String)
|
|
{
|
|
var typeStr = typeProp.GetString();
|
|
if (Enum.TryParse<LinkedChildType>(typeStr, out var parsedType))
|
|
{
|
|
childType = parsedType;
|
|
}
|
|
}
|
|
}
|
|
|
|
linkedChildrenToAdd.Add(new LinkedChildEntity
|
|
{
|
|
ParentId = item.Id,
|
|
ChildId = childId.Value,
|
|
ChildType = childType,
|
|
SortOrder = isPlaylist ? sortOrder : null
|
|
});
|
|
|
|
sortOrder++;
|
|
}
|
|
|
|
processedCount++;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id);
|
|
}
|
|
}
|
|
|
|
if (linkedChildrenToAdd.Count > 0)
|
|
{
|
|
_logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count);
|
|
|
|
var existingKeys = context.LinkedChildren
|
|
.Select(lc => new { lc.ParentId, lc.ChildId })
|
|
.ToHashSet();
|
|
|
|
var toInsert = linkedChildrenToAdd
|
|
.Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
|
|
.ToList();
|
|
|
|
if (toInsert.Count > 0)
|
|
{
|
|
// Deduplicate by composite key (ParentId, ChildId)
|
|
// Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
|
|
toInsert = toInsert
|
|
.OrderBy(lc => lc.ChildType switch
|
|
{
|
|
LinkedChildType.LocalAlternateVersion => 0,
|
|
LinkedChildType.LinkedAlternateVersion => 1,
|
|
_ => 2
|
|
})
|
|
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
|
|
.ToList();
|
|
|
|
var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
|
|
var existingChildIds = context.BaseItems
|
|
.WhereOneOrMany(childIds, b => b.Id)
|
|
.Select(b => b.Id)
|
|
.ToHashSet();
|
|
|
|
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
|
|
|
|
context.LinkedChildren.AddRange(toInsert);
|
|
context.SaveChanges();
|
|
|
|
_logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("All LinkedChildren records already exist, nothing to insert.");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("No LinkedChildren data found to migrate.");
|
|
}
|
|
|
|
_logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
|
|
|
|
CleanupWrongTypeAlternateVersions(context);
|
|
CleanupOrphanedAlternateVersionBaseItems(context);
|
|
CleanupItemsFromDeletedLibraries(context);
|
|
CleanupStaleFileEntries(context);
|
|
CleanupOrphanedLinkedChildren(context);
|
|
}
|
|
|
|
private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context)
|
|
{
|
|
_logger.LogInformation("Cleaning up alternate version items with wrong type...");
|
|
|
|
// Find all LocalAlternateVersion relationships where the child is a generic Video
|
|
// but the parent is a more specific type (like Movie).
|
|
// Since IDs are computed from type + path, just updating the Type column would break ID lookups.
|
|
// Instead, delete them and let the runtime recreate them with the correct type during the next library scan.
|
|
var wrongTypeChildIds = context.LinkedChildren
|
|
.Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion)
|
|
.Join(
|
|
context.BaseItems,
|
|
lc => lc.ParentId,
|
|
parent => parent.Id,
|
|
(lc, parent) => new { lc.ChildId, ParentType = parent.Type })
|
|
.Join(
|
|
context.BaseItems,
|
|
x => x.ChildId,
|
|
child => child.Id,
|
|
(x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type })
|
|
.Where(x => x.ChildType != x.ParentType)
|
|
.Select(x => x.ChildId)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
if (wrongTypeChildIds.Count == 0)
|
|
{
|
|
_logger.LogInformation("No wrong-type alternate version items found.");
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
|
|
|
|
var itemsToDelete = wrongTypeChildIds
|
|
.Select(id => _libraryManager.GetItemById(id))
|
|
.Where(item => item is not null)
|
|
.ToList();
|
|
var deleted = DeleteItems(itemsToDelete!);
|
|
|
|
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted);
|
|
}
|
|
|
|
private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
|
|
{
|
|
_logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems...");
|
|
|
|
// Find BaseItems that have OwnerId set (they belonged to another item) and are not extras,
|
|
// but no LinkedChild entry references them — meaning they're orphaned alternate versions.
|
|
// This happens when a version file is renamed: the old BaseItem remains in the DB
|
|
// with a stale OwnerId but nothing links to it anymore.
|
|
var orphanedVersionIds = context.BaseItems
|
|
.Where(b => b.OwnerId.HasValue && b.ExtraType == null)
|
|
.Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id)))
|
|
.Select(b => b.Id)
|
|
.ToList();
|
|
|
|
if (orphanedVersionIds.Count == 0)
|
|
{
|
|
_logger.LogInformation("No orphaned alternate version BaseItems found.");
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count);
|
|
|
|
var itemsToDelete = orphanedVersionIds
|
|
.Select(id => _libraryManager.GetItemById(id))
|
|
.Where(item => item is not null)
|
|
.ToList();
|
|
var deleted = DeleteItems(itemsToDelete!);
|
|
|
|
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
|
|
}
|
|
|
|
private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
|
|
{
|
|
_logger.LogInformation("Starting cleanup of items from deleted libraries...");
|
|
|
|
// Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists.
|
|
// This happens when a library is removed but the scan didn't fully clean up all items under it.
|
|
var orphanedIds = context.BaseItems
|
|
.Where(b => b.TopParentId.HasValue)
|
|
.Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value)))
|
|
.Select(b => b.Id)
|
|
.ToList();
|
|
|
|
if (orphanedIds.Count == 0)
|
|
{
|
|
_logger.LogInformation("No items from deleted libraries found.");
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
|
|
|
|
var itemsToDelete = orphanedIds
|
|
.Select(id => _libraryManager.GetItemById(id))
|
|
.Where(item => item is not null)
|
|
.ToList();
|
|
var deleted = DeleteItems(itemsToDelete!);
|
|
|
|
_logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
|
|
}
|
|
|
|
private void CleanupStaleFileEntries(JellyfinDbContext context)
|
|
{
|
|
_logger.LogInformation("Starting cleanup of items with missing files...");
|
|
|
|
// Get all library media locations and partition into accessible vs inaccessible.
|
|
// This mirrors the scanner's safeguard: if a library root is inaccessible
|
|
// (e.g. NAS offline), we skip items under it to avoid false deletions.
|
|
var virtualFolders = _libraryManager.GetVirtualFolders();
|
|
var accessiblePaths = new List<string>();
|
|
var inaccessiblePaths = new List<string>();
|
|
|
|
foreach (var folder in virtualFolders)
|
|
{
|
|
foreach (var location in folder.Locations)
|
|
{
|
|
if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any())
|
|
{
|
|
accessiblePaths.Add(location);
|
|
}
|
|
else
|
|
{
|
|
inaccessiblePaths.Add(location);
|
|
_logger.LogWarning(
|
|
"Library location {Path} is inaccessible or empty, skipping file existence checks for items under this path.",
|
|
location);
|
|
}
|
|
}
|
|
}
|
|
|
|
var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList();
|
|
|
|
// Get all non-folder, non-virtual items with paths from the DB
|
|
var itemsWithPaths = context.BaseItems
|
|
.Where(b => b.Path != null && b.Path != string.Empty)
|
|
.Where(b => !b.IsFolder && !b.IsVirtualItem)
|
|
.Select(b => new { b.Id, b.Path })
|
|
.ToList();
|
|
|
|
var internalMetadataPath = _appPaths.InternalMetadataPath;
|
|
|
|
var staleIds = new List<Guid>();
|
|
foreach (var item in itemsWithPaths)
|
|
{
|
|
// Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
|
|
var path = _appHost.ExpandVirtualPath(item.Path!);
|
|
|
|
// Skip items stored under internal metadata (images, subtitles, trickplay, etc.)
|
|
if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
// Item is under an accessible library location — check if it still exists
|
|
// Directory check covers BDMV/DVD items whose Path points to a folder
|
|
if (!File.Exists(path) && !Directory.Exists(path))
|
|
{
|
|
staleIds.Add(item.Id);
|
|
}
|
|
}
|
|
else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
// Item is not under ANY library location (accessible or not) —
|
|
// it's orphaned from all libraries (e.g. media path was removed from config)
|
|
staleIds.Add(item.Id);
|
|
}
|
|
|
|
// Otherwise: item is under an inaccessible location — skip (storage may be offline)
|
|
}
|
|
|
|
if (staleIds.Count == 0)
|
|
{
|
|
_logger.LogInformation("No stale items found.");
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
|
|
|
|
var itemsToDelete = staleIds
|
|
.Select(id => _libraryManager.GetItemById(id))
|
|
.Where(item => item is not null)
|
|
.ToList();
|
|
var deleted = DeleteItems(itemsToDelete!);
|
|
|
|
_logger.LogInformation("Removed {Count} stale items.", deleted);
|
|
}
|
|
|
|
private int DeleteItems(IReadOnlyCollection<BaseItem> items)
|
|
{
|
|
if (items.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
|
|
var deleted = 0;
|
|
foreach (var item in items)
|
|
{
|
|
try
|
|
{
|
|
_libraryManager.DeleteItem(item, options);
|
|
deleted++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unknown");
|
|
}
|
|
}
|
|
|
|
return deleted;
|
|
}
|
|
|
|
private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
|
|
{
|
|
_logger.LogInformation("Starting cleanup of orphaned LinkedChildren records...");
|
|
|
|
// Find all LinkedChildren where the ChildId doesn't exist in BaseItems
|
|
var orphanedLinkedChildren = context.LinkedChildren
|
|
.Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId)))
|
|
.ToList();
|
|
|
|
if (orphanedLinkedChildren.Count == 0)
|
|
{
|
|
_logger.LogInformation("No orphaned LinkedChildren found.");
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count);
|
|
|
|
var orphanedByParent = context.LinkedChildren
|
|
.Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId)))
|
|
.ToList();
|
|
|
|
if (orphanedByParent.Count > 0)
|
|
{
|
|
_logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count);
|
|
orphanedLinkedChildren.AddRange(orphanedByParent);
|
|
}
|
|
|
|
// Remove all orphaned records
|
|
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList();
|
|
context.LinkedChildren.RemoveRange(distinctOrphaned);
|
|
context.SaveChanges();
|
|
|
|
_logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
|
|
}
|
|
|
|
private void ProcessVideoAlternateVersions(
|
|
JsonElement root,
|
|
Guid parentId,
|
|
Dictionary<string, Guid> pathToIdMap,
|
|
List<LinkedChildEntity> linkedChildrenToAdd)
|
|
{
|
|
int sortOrder = 0;
|
|
|
|
if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement)
|
|
&& localAlternateVersionsElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var pathElement in localAlternateVersionsElement.EnumerateArray())
|
|
{
|
|
if (pathElement.ValueKind != JsonValueKind.String)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = pathElement.GetString();
|
|
if (string.IsNullOrEmpty(path))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Try to resolve the path to an ItemId
|
|
if (pathToIdMap.TryGetValue(path, out var childId))
|
|
{
|
|
linkedChildrenToAdd.Add(new LinkedChildEntity
|
|
{
|
|
ParentId = parentId,
|
|
ChildId = childId,
|
|
ChildType = LinkedChildType.LocalAlternateVersion,
|
|
SortOrder = sortOrder++
|
|
});
|
|
|
|
_logger.LogDebug(
|
|
"Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}",
|
|
parentId,
|
|
childId,
|
|
path);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}",
|
|
path,
|
|
parentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement)
|
|
&& linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
|
|
{
|
|
Guid? childId = null;
|
|
|
|
// Try to get ItemId
|
|
if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
|
|
{
|
|
var itemIdStr = itemIdProp.GetString();
|
|
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
|
|
{
|
|
childId = parsedId;
|
|
}
|
|
}
|
|
|
|
// Try to get from Path if ItemId not available
|
|
if (!childId.HasValue || childId.Value.IsEmpty())
|
|
{
|
|
if (linkedChildElement.TryGetProperty("Path", out var pathProp))
|
|
{
|
|
var path = pathProp.GetString();
|
|
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
|
|
{
|
|
childId = resolvedId;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try LibraryItemId as fallback
|
|
if (!childId.HasValue || childId.Value.IsEmpty())
|
|
{
|
|
if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp))
|
|
{
|
|
var libIdStr = libIdProp.GetString();
|
|
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
|
|
{
|
|
childId = parsedLibId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!childId.HasValue || childId.Value.IsEmpty())
|
|
{
|
|
_logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId);
|
|
continue;
|
|
}
|
|
|
|
linkedChildrenToAdd.Add(new LinkedChildEntity
|
|
{
|
|
ParentId = parentId,
|
|
ChildId = childId.Value,
|
|
ChildType = LinkedChildType.LinkedAlternateVersion,
|
|
SortOrder = sortOrder++
|
|
});
|
|
|
|
_logger.LogDebug(
|
|
"Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}",
|
|
parentId,
|
|
childId.Value);
|
|
}
|
|
}
|
|
}
|
|
}
|