< Summary - Jellyfin

Information
Class: Jellyfin.Server.Implementations.Item.ItemPersistenceService
Assembly: Jellyfin.Server.Implementations
File(s): /srv/git/jellyfin/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
Line coverage
59%
Covered lines: 232
Uncovered lines: 161
Coverable lines: 393
Total lines: 664
Line coverage: 59%
Branch coverage
43%
Covered branches: 60
Total branches: 138
Branch coverage: 43.4%
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: 59% (232/393) Branch coverage: 43.4% (60/138) Total lines: 664 5/4/2026 - 12:15:16 AM Line coverage: 59% (232/393) Branch coverage: 43.4% (60/138) Total lines: 664

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
DeleteItem(...)58.33%121292.64%
UpdateInheritedValues()100%11100%
SaveItems(...)100%11100%
SaveImagesAsync()0%620%
ReattachUserDataAsync()100%11100%
UpdateOrInsertItems(...)42.5%220512047.49%
GetItemValuesToSave(...)50%4481.81%

File(s)

/srv/git/jellyfin/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Jellyfin.Database.Implementations;
 9using Jellyfin.Database.Implementations.Entities;
 10using Jellyfin.Extensions;
 11using MediaBrowser.Controller;
 12using MediaBrowser.Controller.Entities;
 13using MediaBrowser.Controller.Entities.Audio;
 14using MediaBrowser.Controller.Persistence;
 15using MediaBrowser.Controller.Playlists;
 16using Microsoft.EntityFrameworkCore;
 17using Microsoft.Extensions.Logging;
 18using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
 19using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
 20using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
 21
 22namespace Jellyfin.Server.Implementations.Item;
 23
 24/// <summary>
 25/// Handles item persistence operations (save, delete, update).
 26/// </summary>
 27public class ItemPersistenceService : IItemPersistenceService
 28{
 29    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 30    private readonly IServerApplicationHost _appHost;
 31    private readonly ILogger<ItemPersistenceService> _logger;
 32
 33    /// <summary>
 34    /// Initializes a new instance of the <see cref="ItemPersistenceService"/> class.
 35    /// </summary>
 36    /// <param name="dbProvider">The database context factory.</param>
 37    /// <param name="appHost">The application host.</param>
 38    /// <param name="logger">The logger.</param>
 39    public ItemPersistenceService(
 40        IDbContextFactory<JellyfinDbContext> dbProvider,
 41        IServerApplicationHost appHost,
 42        ILogger<ItemPersistenceService> logger)
 43    {
 2144        _dbProvider = dbProvider;
 2145        _appHost = appHost;
 2146        _logger = logger;
 2147    }
 48
 49    /// <inheritdoc />
 50    public void DeleteItem(params IReadOnlyList<Guid> ids)
 51    {
 152        if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(BaseItemRepository.PlaceholderId)))
 53        {
 054            throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids));
 55        }
 56
 157        using var context = _dbProvider.CreateDbContext();
 158        using var transaction = context.Database.BeginTransaction();
 59
 160        var date = (DateTime?)DateTime.UtcNow;
 61
 162        var descendantIds = DescendantQueryHelper.GetOwnedDescendantIdsBatch(context, ids);
 463        foreach (var id in ids)
 64        {
 165            descendantIds.Add(id);
 66        }
 67
 168        var extraIds = context.BaseItems
 169            .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
 170            .Select(e => e.Id)
 171            .ToArray();
 72
 273        foreach (var extraId in extraIds)
 74        {
 075            descendantIds.Add(extraId);
 76        }
 77
 178        var relatedItems = descendantIds.ToArray();
 79
 80        // When batch-deleting, multiple items may have UserData for the same (UserId, CustomDataKey).
 81        // Moving all of them to PlaceholderId would violate the UNIQUE constraint.
 82        // Deduplicate by loading keys client-side, keeping the best row per group.
 183        var batchUserData = context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId);
 84
 185        var allRows = batchUserData
 186            .Select(ud => new { ud.ItemId, ud.UserId, ud.CustomDataKey, ud.LastPlayedDate, ud.PlayCount })
 187            .ToList();
 88
 189        var duplicateRows = allRows
 190            .GroupBy(ud => new { ud.UserId, ud.CustomDataKey })
 191            .Where(g => g.Count() > 1)
 192            .SelectMany(g => g
 193                .OrderByDescending(ud => ud.LastPlayedDate)
 194                .ThenByDescending(ud => ud.PlayCount)
 195                .Skip(1))
 196            .ToList();
 97
 298        foreach (var dup in duplicateRows)
 99        {
 0100            context.UserData
 0101                .Where(ud => ud.ItemId == dup.ItemId && ud.UserId == dup.UserId && ud.CustomDataKey == dup.CustomDataKey
 0102                .ExecuteDelete();
 103        }
 104
 105        // Delete existing placeholder rows that would conflict with the incoming ones
 1106        context.UserData
 1107            .Join(
 1108                batchUserData,
 1109                placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
 1110                userData => new { userData.UserId, userData.CustomDataKey },
 1111                (placeholder, userData) => placeholder)
 1112            .Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
 1113            .ExecuteDelete();
 114
 1115        batchUserData
 1116            .ExecuteUpdate(e => e
 1117                .SetProperty(f => f.RetentionDate, date)
 1118                .SetProperty(f => f.ItemId, BaseItemRepository.PlaceholderId));
 119
 1120        context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1121        context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete();
 1122        context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1123        context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1124        context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1125        context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1126        context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1127        context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1128        context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1129        context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1130        context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
 1131        context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1132        context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
 1133        context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
 1134        context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
 1135        context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1136        context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1137        context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1138        var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distin
 1139        context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1140        context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
 1141        context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1142        context.SaveChanges();
 1143        transaction.Commit();
 2144    }
 145
 146    /// <inheritdoc />
 147    public void UpdateInheritedValues()
 148    {
 17149        using var context = _dbProvider.CreateDbContext();
 17150        using var transaction = context.Database.BeginTransaction();
 151
 17152        context.ItemValuesMap.Where(e => e.ItemValue.Type == ItemValueType.InheritedTags).ExecuteDelete();
 17153        context.SaveChanges();
 154
 17155        transaction.Commit();
 34156    }
 157
 158    /// <inheritdoc />
 159    public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
 160    {
 113161        UpdateOrInsertItems(items, cancellationToken);
 113162    }
 163
 164    /// <inheritdoc />
 165    public async Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default)
 166    {
 0167        ArgumentNullException.ThrowIfNull(item);
 168
 0169        var images = item.ImageInfos.Select(e => BaseItemMapper.MapImageToEntity(item.Id, e)).ToArray();
 170
 0171        var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0172        await using (context.ConfigureAwait(false))
 173        {
 0174            if (!await context.BaseItems
 0175                .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
 0176                .ConfigureAwait(false))
 177            {
 0178                _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
 0179                return;
 180            }
 181
 0182            await context.BaseItemImageInfos
 0183                .Where(e => e.ItemId == item.Id)
 0184                .ExecuteDeleteAsync(cancellationToken)
 0185                .ConfigureAwait(false);
 186
 0187            await context.BaseItemImageInfos
 0188                .AddRangeAsync(images, cancellationToken)
 0189                .ConfigureAwait(false);
 190
 0191            await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 192        }
 0193    }
 194
 195    /// <inheritdoc />
 196    public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
 197    {
 34198        ArgumentNullException.ThrowIfNull(item);
 34199        cancellationToken.ThrowIfCancellationRequested();
 200
 33201        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 202
 33203        await using (dbContext.ConfigureAwait(false))
 204        {
 33205            var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
 33206            await using (transaction.ConfigureAwait(false))
 207            {
 33208                var userKeys = item.GetUserDataKeys().ToArray();
 33209                var retentionDate = (DateTime?)null;
 210
 33211                await dbContext.UserData
 33212                    .Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
 33213                    .Where(e => userKeys.Contains(e.CustomDataKey))
 33214                    .ExecuteUpdateAsync(
 33215                        e => e
 33216                            .SetProperty(f => f.ItemId, item.Id)
 33217                            .SetProperty(f => f.RetentionDate, retentionDate),
 33218                        cancellationToken).ConfigureAwait(false);
 219
 33220                item.UserData = await dbContext.UserData
 33221                    .AsNoTracking()
 33222                    .Where(e => e.ItemId == item.Id)
 33223                    .ToArrayAsync(cancellationToken)
 33224                    .ConfigureAwait(false);
 225
 33226                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
 33227            }
 33228        }
 33229    }
 230
 231    private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
 232    {
 113233        ArgumentNullException.ThrowIfNull(items);
 113234        cancellationToken.ThrowIfCancellationRequested();
 235
 113236        var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> Use
 452237        foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != BaseItemRepository.Placeh
 238        {
 113239            var ancestorIds = item.SupportsAncestors ?
 113240                item.GetAncestorIds().Distinct().ToList() :
 113241                null;
 242
 113243            var topParent = item.GetTopParent();
 244
 113245            var userdataKey = item.GetUserDataKeys();
 113246            var inheritedTags = item.GetInheritedTags();
 247
 113248            tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags));
 249        }
 250
 113251        using var context = _dbProvider.CreateDbContext();
 113252        using var transaction = context.Database.BeginTransaction();
 253
 113254        var ids = tuples.Select(f => f.Item.Id).ToArray();
 113255        var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
 256
 452257        foreach (var item in tuples)
 258        {
 113259            var entity = BaseItemMapper.Map(item.Item, _appHost);
 113260            entity.TopParentId = item.TopParent?.Id;
 261
 113262            if (!existingItems.Any(e => e == entity.Id))
 263            {
 60264                context.BaseItems.Add(entity);
 265            }
 266            else
 267            {
 53268                context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
 53269                context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
 53270                context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
 271
 53272                if (entity.Images is { Count: > 0 })
 273                {
 0274                    context.BaseItemImageInfos.AddRange(entity.Images);
 275                }
 276
 53277                if (entity.LockedFields is { Count: > 0 })
 278                {
 0279                    context.BaseItemMetadataFields.AddRange(entity.LockedFields);
 280                }
 281
 53282                context.BaseItems.Attach(entity).State = EntityState.Modified;
 283            }
 284        }
 285
 113286        var itemValueMaps = tuples
 113287            .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
 113288            .ToArray();
 113289        var allListedItemValues = itemValueMaps
 113290            .SelectMany(f => f.Values)
 113291            .Distinct()
 113292            .ToArray();
 293
 113294        var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray();
 113295        var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray();
 113296        var allListedItemValuesSet = allListedItemValues.ToHashSet();
 297
 113298        var existingValues = context.ItemValues
 113299            .Where(e => types.Contains(e.Type) && values.Contains(e.Value))
 113300            .AsEnumerable()
 113301            .Where(e => allListedItemValuesSet.Contains((e.Type, e.Value)))
 113302            .ToArray();
 113303        var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).S
 113304        {
 113305            CleanValue = f.Value.GetCleanValue(),
 113306            ItemValueId = Guid.NewGuid(),
 113307            Type = f.MagicNumber,
 113308            Value = f.Value
 113309        }).ToArray();
 113310        context.ItemValues.AddRange(missingItemValues);
 311
 113312        var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
 113313        var valueMap = itemValueMaps
 113314            .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type =
 113315            .ToArray();
 316
 113317        var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
 318
 452319        foreach (var item in valueMap)
 320        {
 113321            var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList();
 226322            foreach (var itemValue in item.Values)
 323            {
 0324                var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId);
 0325                if (existingItem is null)
 326                {
 0327                    context.ItemValuesMap.Add(new ItemValueMap()
 0328                    {
 0329                        Item = null!,
 0330                        ItemId = item.Item.Id,
 0331                        ItemValue = null!,
 0332                        ItemValueId = itemValue.ItemValueId
 0333                    });
 334                }
 335                else
 336                {
 0337                    itemMappedValues.Remove(existingItem);
 338                }
 339            }
 340
 113341            context.ItemValuesMap.RemoveRange(itemMappedValues);
 342        }
 343
 113344        var itemsWithAncestors = tuples
 113345            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 113346            .Select(t => t.Item.Id)
 113347            .ToList();
 348
 113349        var allExistingAncestorIds = itemsWithAncestors.Count > 0
 113350            ? context.AncestorIds
 113351                .Where(e => itemsWithAncestors.Contains(e.ItemId))
 113352                .ToList()
 113353                .GroupBy(e => e.ItemId)
 113354                .ToDictionary(g => g.Key, g => g.ToList())
 113355            : new Dictionary<Guid, List<AncestorId>>();
 356
 113357        var allRequestedAncestorIds = tuples
 113358            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 113359            .SelectMany(t => t.AncestorIds!)
 113360            .Distinct()
 113361            .ToList();
 362
 113363        var validAncestorIdsSet = allRequestedAncestorIds.Count > 0
 113364            ? context.BaseItems
 113365                .Where(e => allRequestedAncestorIds.Contains(e.Id))
 113366                .Select(f => f.Id)
 113367                .ToHashSet()
 113368            : new HashSet<Guid>();
 369
 452370        foreach (var item in tuples)
 371        {
 113372            if (item.Item.SupportsAncestors && item.AncestorIds != null)
 373            {
 113374                var existingAncestorIds = allExistingAncestorIds.GetValueOrDefault(item.Item.Id) ?? new List<AncestorId>
 113375                var validAncestorIds = item.AncestorIds.Where(id => validAncestorIdsSet.Contains(id)).ToArray();
 280376                foreach (var ancestorId in validAncestorIds)
 377                {
 27378                    var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId);
 27379                    if (existingAncestorId is null)
 380                    {
 23381                        context.AncestorIds.Add(new AncestorId()
 23382                        {
 23383                            ParentItemId = ancestorId,
 23384                            ItemId = item.Item.Id,
 23385                            Item = null!,
 23386                            ParentItem = null!
 23387                        });
 388                    }
 389                    else
 390                    {
 4391                        existingAncestorIds.Remove(existingAncestorId);
 392                    }
 393                }
 394
 113395                context.AncestorIds.RemoveRange(existingAncestorIds);
 396            }
 397        }
 398
 113399        context.SaveChanges();
 400
 113401        var folderIds = tuples
 113402            .Where(t => t.Item is Folder)
 113403            .Select(t => t.Item.Id)
 113404            .ToList();
 405
 113406        var videoIds = tuples
 113407            .Where(t => t.Item is Video)
 113408            .Select(t => t.Item.Id)
 113409            .ToList();
 410
 113411        var allLinkedChildrenByParent = new Dictionary<Guid, List<LinkedChildEntity>>();
 113412        if (folderIds.Count > 0 || videoIds.Count > 0)
 413        {
 113414            var allParentIds = folderIds.Concat(videoIds).Distinct().ToList();
 113415            var allLinkedChildren = context.LinkedChildren
 113416                .Where(e => allParentIds.Contains(e.ParentId))
 113417                .ToList();
 418
 113419            allLinkedChildrenByParent = allLinkedChildren
 113420                .GroupBy(e => e.ParentId)
 113421                .ToDictionary(g => g.Key, g => g.ToList());
 422        }
 423
 452424        foreach (var item in tuples)
 425        {
 113426            if (item.Item is Folder folder)
 427            {
 113428                var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new 
 113429                if (folder.LinkedChildren.Length > 0)
 430                {
 431#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
 0432                    var pathsToResolve = folder.LinkedChildren
 0433                        .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path
 0434                        .Select(lc => lc.Path)
 0435                        .Distinct()
 0436                        .ToList();
 437
 0438                    var pathToIdMap = pathsToResolve.Count > 0
 0439                        ? context.BaseItems
 0440                            .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 0441                            .Select(e => new { e.Path, e.Id })
 0442                            .GroupBy(e => e.Path!)
 0443                            .ToDictionary(g => g.Key, g => g.First().Id)
 0444                        : [];
 445
 0446                    var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
 0447                    foreach (var linkedChild in folder.LinkedChildren)
 448                    {
 0449                        var childItemId = linkedChild.ItemId;
 0450                        if (!childItemId.HasValue || childItemId.Value.IsEmpty())
 451                        {
 0452                            if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out
 453                            {
 0454                                childItemId = resolvedId;
 455                            }
 456                        }
 457#pragma warning restore CS0618
 458
 0459                        if (childItemId.HasValue && !childItemId.Value.IsEmpty())
 460                        {
 0461                            resolvedChildren.Add((linkedChild, childItemId.Value));
 462                        }
 463                    }
 464
 0465                    resolvedChildren = resolvedChildren
 0466                        .GroupBy(c => c.ChildId)
 0467                        .Select(g => g.Last())
 0468                        .ToList();
 469
 0470                    var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
 0471                    var existingChildIds = childIdsToCheck.Count > 0
 0472                        ? context.BaseItems
 0473                            .Where(e => childIdsToCheck.Contains(e.Id))
 0474                            .Select(e => e.Id)
 0475                            .ToHashSet()
 0476                        : [];
 477
 0478                    var isPlaylist = folder is Playlist;
 0479                    var sortOrder = 0;
 0480                    foreach (var (linkedChild, childId) in resolvedChildren)
 481                    {
 0482                        if (!existingChildIds.Contains(childId))
 483                        {
 0484                            _logger.LogWarning(
 0485                                "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does no
 0486                                item.Item.Name,
 0487                                item.Item.Id,
 0488                                childId);
 0489                            continue;
 490                        }
 491
 0492                        var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
 0493                        if (existingLink is null)
 494                        {
 0495                            context.LinkedChildren.Add(new LinkedChildEntity()
 0496                            {
 0497                                ParentId = item.Item.Id,
 0498                                ChildId = childId,
 0499                                ChildType = (DbLinkedChildType)linkedChild.Type,
 0500                                SortOrder = isPlaylist ? sortOrder : null
 0501                            });
 502                        }
 503                        else
 504                        {
 0505                            existingLink.SortOrder = isPlaylist ? sortOrder : null;
 0506                            existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
 0507                            existingLinkedChildren.Remove(existingLink);
 508                        }
 509
 0510                        sortOrder++;
 511                    }
 512                }
 513
 113514                if (existingLinkedChildren.Count > 0)
 515                {
 0516                    context.LinkedChildren.RemoveRange(existingLinkedChildren);
 517                }
 518            }
 519
 113520            if (item.Item is Video video)
 521            {
 0522                var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedCh
 0523                    .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
 0524                    .ToList();
 525
 0526                var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
 527
 0528                if (video.LocalAlternateVersions.Length > 0)
 529                {
 0530                    var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList();
 0531                    if (pathsToResolve.Count > 0)
 532                    {
 0533                        var pathToIdMap = context.BaseItems
 0534                            .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 0535                            .Select(e => new { e.Path, e.Id })
 0536                            .GroupBy(e => e.Path!)
 0537                            .ToDictionary(g => g.Key, g => g.First().Id);
 538
 0539                        foreach (var path in pathsToResolve)
 540                        {
 0541                            if (pathToIdMap.TryGetValue(path, out var childId))
 542                            {
 0543                                newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion));
 544                            }
 545                        }
 546                    }
 547                }
 548
 0549                if (video.LinkedAlternateVersions.Length > 0)
 550                {
 0551                    foreach (var linkedChild in video.LinkedAlternateVersions)
 552                    {
 0553                        if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty())
 554                        {
 0555                            newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion));
 556                        }
 557                    }
 558                }
 559
 0560                newLinkedChildren = newLinkedChildren
 0561                    .GroupBy(c => c.ChildId)
 0562                    .Select(g => g.Last())
 0563                    .ToList();
 564
 0565                var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
 0566                var existingChildIds = childIdsToCheck.Count > 0
 0567                    ? context.BaseItems
 0568                        .Where(e => childIdsToCheck.Contains(e.Id))
 0569                        .Select(e => e.Id)
 0570                        .ToHashSet()
 0571                    : [];
 572
 0573                int sortOrder = 0;
 0574                foreach (var (childId, childType) in newLinkedChildren)
 575                {
 0576                    if (!existingChildIds.Contains(childId))
 577                    {
 0578                        _logger.LogWarning(
 0579                            "Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not
 0580                            video.Name,
 0581                            video.Id,
 0582                            childId);
 0583                        continue;
 584                    }
 585
 0586                    var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
 0587                    if (existingLink is null)
 588                    {
 0589                        context.LinkedChildren.Add(new LinkedChildEntity
 0590                        {
 0591                            ParentId = video.Id,
 0592                            ChildId = childId,
 0593                            ChildType = (DbLinkedChildType)childType,
 0594                            SortOrder = sortOrder
 0595                        });
 596                    }
 597                    else
 598                    {
 0599                        existingLink.ChildType = (DbLinkedChildType)childType;
 0600                        existingLink.SortOrder = sortOrder;
 0601                        existingLinkedChildren.Remove(existingLink);
 602                    }
 603
 0604                    sortOrder++;
 605                }
 606
 0607                if (existingLinkedChildren.Count > 0)
 608                {
 0609                    var orphanedLocalVersionIds = existingLinkedChildren
 0610                        .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
 0611                        .Select(e => e.ChildId)
 0612                        .ToList();
 613
 0614                    context.LinkedChildren.RemoveRange(existingLinkedChildren);
 615
 0616                    if (orphanedLocalVersionIds.Count > 0)
 617                    {
 0618                        var orphanedItems = context.BaseItems
 0619                            .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id)
 0620                            .ToList();
 621
 0622                        if (orphanedItems.Count > 0)
 623                        {
 0624                            _logger.LogInformation(
 0625                                "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})
 0626                                orphanedItems.Count,
 0627                                video.Name,
 0628                                video.Id);
 0629                            context.BaseItems.RemoveRange(orphanedItems);
 630                        }
 631                    }
 632                }
 633            }
 634        }
 635
 113636        context.SaveChanges();
 113637        transaction.Commit();
 226638    }
 639
 640    private static List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> in
 641    {
 113642        var list = new List<(ItemValueType, string)>();
 643
 113644        if (item is IHasArtist hasArtist)
 645        {
 0646            list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i)));
 647        }
 648
 113649        if (item is IHasAlbumArtist hasAlbumArtist)
 650        {
 0651            list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i)));
 652        }
 653
 113654        list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i)));
 113655        list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i)));
 113656        list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i)));
 657
 113658        list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i)));
 659
 113660        list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
 661
 113662        return list;
 663    }
 664}