< 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: 234
Uncovered lines: 161
Coverable lines: 395
Total lines: 671
Line coverage: 59.2%
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: 6646/9/2026 - 12:16:23 AM Line coverage: 59% (232/393) Branch coverage: 43.4% (60/138) Total lines: 6666/27/2026 - 12:15:59 AM Line coverage: 59.2% (234/395) Branch coverage: 43.4% (60/138) Total lines: 671 5/4/2026 - 12:15:16 AM Line coverage: 59% (232/393) Branch coverage: 43.4% (60/138) Total lines: 6646/9/2026 - 12:16:23 AM Line coverage: 59% (232/393) Branch coverage: 43.4% (60/138) Total lines: 6666/27/2026 - 12:15:59 AM Line coverage: 59.2% (234/395) Branch coverage: 43.4% (60/138) Total lines: 671

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
DeleteItem(...)58.33%121292.85%
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    {
 2244        _dbProvider = dbProvider;
 2245        _appHost = appHost;
 2246        _logger = logger;
 2247    }
 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
 68        // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
 69        // single parameter (json_each) rather than one SQL variable per id, which would otherwise
 70        // overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
 171        var ownerIds = descendantIds.ToArray();
 172        var extraIds = context.BaseItems
 173            .Where(e => e.OwnerId.HasValue)
 174            .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
 175            .Select(e => e.Id)
 176            .ToArray();
 77
 278        foreach (var extraId in extraIds)
 79        {
 080            descendantIds.Add(extraId);
 81        }
 82
 183        var relatedItems = descendantIds.ToArray();
 84
 85        // When batch-deleting, multiple items may have UserData for the same (UserId, CustomDataKey).
 86        // Moving all of them to PlaceholderId would violate the UNIQUE constraint.
 87        // Deduplicate by loading keys client-side, keeping the best row per group.
 188        var batchUserData = context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId);
 89
 190        var allRows = batchUserData
 191            .Select(ud => new { ud.ItemId, ud.UserId, ud.CustomDataKey, ud.LastPlayedDate, ud.PlayCount })
 192            .ToList();
 93
 194        var duplicateRows = allRows
 195            .GroupBy(ud => new { ud.UserId, ud.CustomDataKey })
 196            .Where(g => g.Count() > 1)
 197            .SelectMany(g => g
 198                .OrderByDescending(ud => ud.LastPlayedDate)
 199                .ThenByDescending(ud => ud.PlayCount)
 1100                .Skip(1))
 1101            .ToList();
 102
 2103        foreach (var dup in duplicateRows)
 104        {
 0105            context.UserData
 0106                .Where(ud => ud.ItemId == dup.ItemId && ud.UserId == dup.UserId && ud.CustomDataKey == dup.CustomDataKey
 0107                .ExecuteDelete();
 108        }
 109
 110        // Delete existing placeholder rows that would conflict with the incoming ones
 1111        context.UserData
 1112            .Join(
 1113                batchUserData,
 1114                placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
 1115                userData => new { userData.UserId, userData.CustomDataKey },
 1116                (placeholder, userData) => placeholder)
 1117            .Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
 1118            .ExecuteDelete();
 119
 1120        batchUserData
 1121            .ExecuteUpdate(e => e
 1122                .SetProperty(f => f.RetentionDate, date)
 1123                .SetProperty(f => f.ItemId, BaseItemRepository.PlaceholderId));
 124
 1125        context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1126        context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete();
 1127        context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1128        context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1129        context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1130        context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1131        context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1132        context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1133        context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1134        context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1135        context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
 1136        context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1137        context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
 1138        context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
 1139        context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
 1140        context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1141        context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1142        context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1143        var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distin
 1144        context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1145        context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
 1146        context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 1147        context.SaveChanges();
 1148        transaction.Commit();
 2149    }
 150
 151    /// <inheritdoc />
 152    public void UpdateInheritedValues()
 153    {
 16154        using var context = _dbProvider.CreateDbContext();
 16155        using var transaction = context.Database.BeginTransaction();
 156
 16157        context.ItemValuesMap.Where(e => e.ItemValue.Type == ItemValueType.InheritedTags).ExecuteDelete();
 16158        context.SaveChanges();
 159
 16160        transaction.Commit();
 32161    }
 162
 163    /// <inheritdoc />
 164    public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
 165    {
 119166        UpdateOrInsertItems(items, cancellationToken);
 118167    }
 168
 169    /// <inheritdoc />
 170    public async Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default)
 171    {
 0172        ArgumentNullException.ThrowIfNull(item);
 173
 0174        var images = item.ImageInfos.Select(e => BaseItemMapper.MapImageToEntity(item.Id, e)).ToArray();
 175
 0176        var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0177        await using (context.ConfigureAwait(false))
 178        {
 0179            if (!await context.BaseItems
 0180                .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
 0181                .ConfigureAwait(false))
 182            {
 0183                _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
 0184                return;
 185            }
 186
 0187            await context.BaseItemImageInfos
 0188                .Where(e => e.ItemId == item.Id)
 0189                .ExecuteDeleteAsync(cancellationToken)
 0190                .ConfigureAwait(false);
 191
 0192            await context.BaseItemImageInfos
 0193                .AddRangeAsync(images, cancellationToken)
 0194                .ConfigureAwait(false);
 195
 0196            await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 197        }
 0198    }
 199
 200    /// <inheritdoc />
 201    public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
 202    {
 35203        ArgumentNullException.ThrowIfNull(item);
 35204        cancellationToken.ThrowIfCancellationRequested();
 205
 35206        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 207
 35208        await using (dbContext.ConfigureAwait(false))
 209        {
 35210            var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
 35211            await using (transaction.ConfigureAwait(false))
 212            {
 35213                var userKeys = item.GetUserDataKeys().ToArray();
 35214                var retentionDate = (DateTime?)null;
 215
 35216                await dbContext.UserData
 35217                    .Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
 35218                    .Where(e => userKeys.Contains(e.CustomDataKey))
 35219                    .ExecuteUpdateAsync(
 35220                        e => e
 35221                            .SetProperty(f => f.ItemId, item.Id)
 35222                            .SetProperty(f => f.RetentionDate, retentionDate),
 35223                        cancellationToken).ConfigureAwait(false);
 224
 35225                item.UserData = await dbContext.UserData
 35226                    .AsNoTracking()
 35227                    .Where(e => e.ItemId == item.Id)
 35228                    .ToArrayAsync(cancellationToken)
 35229                    .ConfigureAwait(false);
 230
 35231                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
 35232            }
 35233        }
 35234    }
 235
 236    private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
 237    {
 119238        ArgumentNullException.ThrowIfNull(items);
 119239        cancellationToken.ThrowIfCancellationRequested();
 240
 118241        var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> Use
 472242        foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != BaseItemRepository.Placeh
 243        {
 118244            var ancestorIds = item.SupportsAncestors ?
 118245                item.GetAncestorIds().Distinct().ToList() :
 118246                null;
 247
 118248            var topParent = item.GetTopParent();
 249
 118250            var userdataKey = item.GetUserDataKeys();
 118251            var inheritedTags = item.GetInheritedTags();
 252
 118253            tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags));
 254        }
 255
 118256        using var context = _dbProvider.CreateDbContext();
 118257        using var transaction = context.Database.BeginTransaction();
 258
 118259        var ids = tuples.Select(f => f.Item.Id).ToArray();
 118260        var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
 261
 472262        foreach (var item in tuples)
 263        {
 118264            var entity = BaseItemMapper.Map(item.Item, _appHost);
 118265            entity.TopParentId = item.TopParent?.Id;
 266
 118267            if (!existingItems.Any(e => e == entity.Id))
 268            {
 63269                context.BaseItems.Add(entity);
 270            }
 271            else
 272            {
 55273                context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
 55274                context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
 55275                context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
 276
 55277                if (entity.Images is { Count: > 0 })
 278                {
 0279                    context.BaseItemImageInfos.AddRange(entity.Images);
 280                }
 281
 55282                if (entity.LockedFields is { Count: > 0 })
 283                {
 0284                    context.BaseItemMetadataFields.AddRange(entity.LockedFields);
 285                }
 286
 55287                context.BaseItems.Attach(entity).State = EntityState.Modified;
 288            }
 289        }
 290
 118291        var itemValueMaps = tuples
 118292            .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
 118293            .ToArray();
 118294        var allListedItemValues = itemValueMaps
 118295            .SelectMany(f => f.Values)
 118296            .Distinct()
 118297            .ToArray();
 298
 118299        var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray();
 118300        var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray();
 118301        var allListedItemValuesSet = allListedItemValues.ToHashSet();
 302
 118303        var existingValues = context.ItemValues
 118304            .Where(e => types.Contains(e.Type) && values.Contains(e.Value))
 118305            .AsEnumerable()
 118306            .Where(e => allListedItemValuesSet.Contains((e.Type, e.Value)))
 118307            .ToArray();
 118308        var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).S
 118309        {
 118310            CleanValue = f.Value.GetCleanValue(),
 118311            ItemValueId = Guid.NewGuid(),
 118312            Type = f.MagicNumber,
 118313            Value = f.Value
 118314        }).ToArray();
 118315        context.ItemValues.AddRange(missingItemValues);
 316
 118317        var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
 118318        var valueMap = itemValueMaps
 118319            .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type =
 118320            .ToArray();
 321
 118322        var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
 323
 472324        foreach (var item in valueMap)
 325        {
 118326            var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList();
 236327            foreach (var itemValue in item.Values)
 328            {
 0329                var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId);
 0330                if (existingItem is null)
 331                {
 0332                    context.ItemValuesMap.Add(new ItemValueMap()
 0333                    {
 0334                        Item = null!,
 0335                        ItemId = item.Item.Id,
 0336                        ItemValue = null!,
 0337                        ItemValueId = itemValue.ItemValueId
 0338                    });
 339                }
 340                else
 341                {
 0342                    itemMappedValues.Remove(existingItem);
 343                }
 344            }
 345
 118346            context.ItemValuesMap.RemoveRange(itemMappedValues);
 347        }
 348
 118349        var itemsWithAncestors = tuples
 118350            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 118351            .Select(t => t.Item.Id)
 118352            .ToList();
 353
 118354        var allExistingAncestorIds = itemsWithAncestors.Count > 0
 118355            ? context.AncestorIds
 118356                .Where(e => itemsWithAncestors.Contains(e.ItemId))
 118357                .ToList()
 118358                .GroupBy(e => e.ItemId)
 118359                .ToDictionary(g => g.Key, g => g.ToList())
 118360            : new Dictionary<Guid, List<AncestorId>>();
 361
 118362        var allRequestedAncestorIds = tuples
 118363            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 118364            .SelectMany(t => t.AncestorIds!)
 118365            .Distinct()
 118366            .ToList();
 367
 118368        var validAncestorIdsSet = allRequestedAncestorIds.Count > 0
 118369            ? context.BaseItems
 118370                .Where(e => allRequestedAncestorIds.Contains(e.Id))
 118371                .Select(f => f.Id)
 118372                .ToHashSet()
 118373            : new HashSet<Guid>();
 374
 472375        foreach (var item in tuples)
 376        {
 118377            if (item.Item.SupportsAncestors && item.AncestorIds != null)
 378            {
 118379                var existingAncestorIds = allExistingAncestorIds.GetValueOrDefault(item.Item.Id) ?? new List<AncestorId>
 118380                var validAncestorIds = item.AncestorIds.Where(id => validAncestorIdsSet.Contains(id)).ToArray();
 292381                foreach (var ancestorId in validAncestorIds)
 382                {
 28383                    var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId);
 28384                    if (existingAncestorId is null)
 385                    {
 24386                        context.AncestorIds.Add(new AncestorId()
 24387                        {
 24388                            ParentItemId = ancestorId,
 24389                            ItemId = item.Item.Id,
 24390                            Item = null!,
 24391                            ParentItem = null!
 24392                        });
 393                    }
 394                    else
 395                    {
 4396                        existingAncestorIds.Remove(existingAncestorId);
 397                    }
 398                }
 399
 118400                context.AncestorIds.RemoveRange(existingAncestorIds);
 401            }
 402        }
 403
 118404        context.SaveChanges();
 405
 118406        var folderIds = tuples
 118407            .Where(t => t.Item is Folder)
 118408            .Select(t => t.Item.Id)
 118409            .ToList();
 410
 118411        var videoIds = tuples
 118412            .Where(t => t.Item is Video)
 118413            .Select(t => t.Item.Id)
 118414            .ToList();
 415
 118416        var allLinkedChildrenByParent = new Dictionary<Guid, List<LinkedChildEntity>>();
 118417        if (folderIds.Count > 0 || videoIds.Count > 0)
 418        {
 118419            var allParentIds = folderIds.Concat(videoIds).Distinct().ToList();
 118420            var allLinkedChildren = context.LinkedChildren
 118421                .Where(e => allParentIds.Contains(e.ParentId))
 118422                .ToList();
 423
 118424            allLinkedChildrenByParent = allLinkedChildren
 118425                .GroupBy(e => e.ParentId)
 118426                .ToDictionary(g => g.Key, g => g.ToList());
 427        }
 428
 472429        foreach (var item in tuples)
 430        {
 118431            if (item.Item is Folder folder)
 432            {
 118433                var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new 
 118434                if (folder.LinkedChildren.Length > 0)
 435                {
 436#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
 0437                    var pathsToResolve = folder.LinkedChildren
 0438                        .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path
 0439                        .Select(lc => lc.Path)
 0440                        .Distinct()
 0441                        .ToList();
 442
 0443                    var pathToIdMap = pathsToResolve.Count > 0
 0444                        ? context.BaseItems
 0445                            .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 0446                            .Select(e => new { e.Path, e.Id })
 0447                            .GroupBy(e => e.Path!)
 0448                            .ToDictionary(g => g.Key, g => g.First().Id)
 0449                        : [];
 450
 0451                    var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
 0452                    foreach (var linkedChild in folder.LinkedChildren)
 453                    {
 0454                        var childItemId = linkedChild.ItemId;
 0455                        if (!childItemId.HasValue || childItemId.Value.IsEmpty())
 456                        {
 0457                            if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out
 458                            {
 0459                                childItemId = resolvedId;
 460                            }
 461                        }
 462#pragma warning restore CS0618
 463
 0464                        if (childItemId.HasValue && !childItemId.Value.IsEmpty())
 465                        {
 0466                            resolvedChildren.Add((linkedChild, childItemId.Value));
 467                        }
 468                    }
 469
 0470                    resolvedChildren = resolvedChildren
 0471                        .GroupBy(c => c.ChildId)
 0472                        .Select(g => g.Last())
 0473                        .ToList();
 474
 0475                    var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
 0476                    var existingChildIds = childIdsToCheck.Count > 0
 0477                        ? context.BaseItems
 0478                            .Where(e => childIdsToCheck.Contains(e.Id))
 0479                            .Select(e => e.Id)
 0480                            .ToHashSet()
 0481                        : [];
 482
 0483                    var isPlaylist = folder is Playlist;
 0484                    var sortOrder = 0;
 0485                    foreach (var (linkedChild, childId) in resolvedChildren)
 486                    {
 0487                        if (!existingChildIds.Contains(childId))
 488                        {
 0489                            _logger.LogWarning(
 0490                                "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does no
 0491                                item.Item.Name,
 0492                                item.Item.Id,
 0493                                childId);
 0494                            continue;
 495                        }
 496
 0497                        var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
 0498                        if (existingLink is null)
 499                        {
 0500                            context.LinkedChildren.Add(new LinkedChildEntity()
 0501                            {
 0502                                ParentId = item.Item.Id,
 0503                                ChildId = childId,
 0504                                ChildType = (DbLinkedChildType)linkedChild.Type,
 0505                                SortOrder = isPlaylist ? sortOrder : null
 0506                            });
 507                        }
 508                        else
 509                        {
 0510                            existingLink.SortOrder = isPlaylist ? sortOrder : null;
 0511                            existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
 0512                            existingLinkedChildren.Remove(existingLink);
 513                        }
 514
 0515                        sortOrder++;
 516                    }
 517                }
 518
 118519                if (existingLinkedChildren.Count > 0)
 520                {
 0521                    context.LinkedChildren.RemoveRange(existingLinkedChildren);
 522                }
 523            }
 524
 118525            if (item.Item is Video video)
 526            {
 0527                var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedCh
 0528                    .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
 0529                    .ToList();
 530
 0531                var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
 532
 0533                if (video.LocalAlternateVersions.Length > 0)
 534                {
 0535                    var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList();
 0536                    if (pathsToResolve.Count > 0)
 537                    {
 0538                        var pathToIdMap = context.BaseItems
 0539                            .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 0540                            .Select(e => new { e.Path, e.Id })
 0541                            .GroupBy(e => e.Path!)
 0542                            .ToDictionary(g => g.Key, g => g.First().Id);
 543
 0544                        foreach (var path in pathsToResolve)
 545                        {
 0546                            if (pathToIdMap.TryGetValue(path, out var childId))
 547                            {
 0548                                newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion));
 549                            }
 550                        }
 551                    }
 552                }
 553
 0554                if (video.LinkedAlternateVersions.Length > 0)
 555                {
 0556                    foreach (var linkedChild in video.LinkedAlternateVersions)
 557                    {
 0558                        if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty())
 559                        {
 0560                            newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion));
 561                        }
 562                    }
 563                }
 564
 565                // Deduplicate; local (file-based) relationships take priority over linked (user-merged)
 566                // ones, matching the LinkedChildren migration.
 0567                newLinkedChildren = newLinkedChildren
 0568                    .GroupBy(c => c.ChildId)
 0569                    .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
 0570                    .ToList();
 571
 0572                var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
 0573                var existingChildIds = childIdsToCheck.Count > 0
 0574                    ? context.BaseItems
 0575                        .Where(e => childIdsToCheck.Contains(e.Id))
 0576                        .Select(e => e.Id)
 0577                        .ToHashSet()
 0578                    : [];
 579
 0580                int sortOrder = 0;
 0581                foreach (var (childId, childType) in newLinkedChildren)
 582                {
 0583                    if (!existingChildIds.Contains(childId))
 584                    {
 0585                        _logger.LogWarning(
 0586                            "Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not
 0587                            video.Name,
 0588                            video.Id,
 0589                            childId);
 0590                        continue;
 591                    }
 592
 0593                    var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
 0594                    if (existingLink is null)
 595                    {
 0596                        context.LinkedChildren.Add(new LinkedChildEntity
 0597                        {
 0598                            ParentId = video.Id,
 0599                            ChildId = childId,
 0600                            ChildType = (DbLinkedChildType)childType,
 0601                            SortOrder = sortOrder
 0602                        });
 603                    }
 604                    else
 605                    {
 0606                        existingLink.ChildType = (DbLinkedChildType)childType;
 0607                        existingLink.SortOrder = sortOrder;
 0608                        existingLinkedChildren.Remove(existingLink);
 609                    }
 610
 0611                    sortOrder++;
 612                }
 613
 0614                if (existingLinkedChildren.Count > 0)
 615                {
 0616                    var orphanedLocalVersionIds = existingLinkedChildren
 0617                        .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
 0618                        .Select(e => e.ChildId)
 0619                        .ToList();
 620
 0621                    context.LinkedChildren.RemoveRange(existingLinkedChildren);
 622
 0623                    if (orphanedLocalVersionIds.Count > 0)
 624                    {
 0625                        var orphanedItems = context.BaseItems
 0626                            .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id)
 0627                            .ToList();
 628
 0629                        if (orphanedItems.Count > 0)
 630                        {
 0631                            _logger.LogInformation(
 0632                                "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})
 0633                                orphanedItems.Count,
 0634                                video.Name,
 0635                                video.Id);
 0636                            context.BaseItems.RemoveRange(orphanedItems);
 637                        }
 638                    }
 639                }
 640            }
 641        }
 642
 118643        context.SaveChanges();
 118644        transaction.Commit();
 236645    }
 646
 647    private static List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> in
 648    {
 118649        var list = new List<(ItemValueType, string)>();
 650
 118651        if (item is IHasArtist hasArtist)
 652        {
 0653            list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i)));
 654        }
 655
 118656        if (item is IHasAlbumArtist hasAlbumArtist)
 657        {
 0658            list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i)));
 659        }
 660
 118661        list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i)));
 118662        list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i)));
 118663        list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i)));
 664
 118665        list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i)));
 666
 118667        list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
 668
 118669        return list;
 670    }
 671}