< 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
62%
Covered lines: 258
Uncovered lines: 158
Coverable lines: 416
Total lines: 707
Line coverage: 62%
Branch coverage
47%
Covered branches: 77
Total branches: 162
Branch coverage: 47.5%
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: 6718/2/2026 - 12:17:28 AM Line coverage: 61.1% (252/412) Branch coverage: 45.6% (73/160) Total lines: 7008/9/2026 - 12:16:58 AM Line coverage: 62% (258/416) Branch coverage: 47.5% (77/162) Total lines: 707 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: 6718/2/2026 - 12:17:28 AM Line coverage: 61.1% (252/412) Branch coverage: 45.6% (73/160) Total lines: 7008/9/2026 - 12:16:58 AM Line coverage: 62% (258/416) Branch coverage: 47.5% (77/162) Total lines: 707

Coverage delta

Coverage delta 3 -3

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(...)45.83%246914451.78%
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    {
 2444        _dbProvider = dbProvider;
 2445        _appHost = appHost;
 2446        _logger = logger;
 2447    }
 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);
 119167    }
 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    {
 34203        ArgumentNullException.ThrowIfNull(item);
 34204        cancellationToken.ThrowIfCancellationRequested();
 205
 34206        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 207
 34208        await using (dbContext.ConfigureAwait(false))
 209        {
 34210            var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
 34211            await using (transaction.ConfigureAwait(false))
 212            {
 34213                var userKeys = item.GetUserDataKeys().ToArray();
 34214                var retentionDate = (DateTime?)null;
 215
 34216                await dbContext.UserData
 34217                    .Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
 34218                    .Where(e => userKeys.Contains(e.CustomDataKey))
 34219                    .ExecuteUpdateAsync(
 34220                        e => e
 34221                            .SetProperty(f => f.ItemId, item.Id)
 34222                            .SetProperty(f => f.RetentionDate, retentionDate),
 34223                        cancellationToken).ConfigureAwait(false);
 224
 34225                item.UserData = await dbContext.UserData
 34226                    .AsNoTracking()
 34227                    .Where(e => e.ItemId == item.Id)
 34228                    .ToArrayAsync(cancellationToken)
 34229                    .ConfigureAwait(false);
 230
 34231                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
 34232            }
 34233        }
 34234    }
 235
 236    private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
 237    {
 119238        ArgumentNullException.ThrowIfNull(items);
 119239        cancellationToken.ThrowIfCancellationRequested();
 240
 119241        var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> Use
 478242        foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != BaseItemRepository.Placeh
 243        {
 120244            var ancestorIds = item.SupportsAncestors ?
 120245                item.GetAncestorIds().Distinct().ToList() :
 120246                null;
 247
 120248            var topParent = item.GetTopParent();
 249
 120250            var userdataKey = item.GetUserDataKeys();
 120251            var inheritedTags = item.GetInheritedTags();
 252
 120253            tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags));
 254        }
 255
 119256        using var context = _dbProvider.CreateDbContext();
 119257        using var transaction = context.Database.BeginTransaction();
 258
 119259        var ids = tuples.Select(f => f.Item.Id).ToArray();
 119260        var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
 261
 478262        foreach (var item in tuples)
 263        {
 120264            var entity = BaseItemMapper.Map(item.Item, _appHost);
 120265            entity.TopParentId = item.TopParent?.Id;
 266
 120267            if (!existingItems.Contains(entity.Id))
 268            {
 65269                context.BaseItems.Add(entity);
 270            }
 271            else
 272            {
 55273                if (entity.Images is { Count: > 0 })
 274                {
 2275                    context.BaseItemImageInfos.AddRange(entity.Images);
 276                }
 277
 55278                if (entity.LockedFields is { Count: > 0 })
 279                {
 1280                    context.BaseItemMetadataFields.AddRange(entity.LockedFields);
 281                }
 282
 55283                context.BaseItems.Attach(entity).State = EntityState.Modified;
 284            }
 285        }
 286
 119287        var itemValueMaps = tuples
 119288            .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
 119289            .ToArray();
 119290        var allListedItemValues = itemValueMaps
 119291            .SelectMany(f => f.Values)
 119292            .Distinct()
 119293            .ToArray();
 294
 119295        var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray();
 119296        var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray();
 119297        var allListedItemValuesSet = allListedItemValues.ToHashSet();
 298
 119299        var existingValues = context.ItemValues
 119300            .Where(e => types.Contains(e.Type) && values.Contains(e.Value))
 119301            .AsEnumerable()
 119302            .Where(e => allListedItemValuesSet.Contains((e.Type, e.Value)))
 119303            .ToArray();
 119304        var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).S
 119305        {
 119306            CleanValue = f.Value.GetCleanValue(),
 119307            ItemValueId = Guid.NewGuid(),
 119308            Type = f.MagicNumber,
 119309            Value = f.Value
 119310        }).ToArray();
 119311        context.ItemValues.AddRange(missingItemValues);
 312
 119313        var itemValuesStore = existingValues
 119314            .Concat(missingItemValues)
 119315            .ToDictionary(e => (e.Type, e.Value));
 119316        var valueMap = itemValueMaps
 119317            .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e =
 119318            .ToArray();
 319
 119320        var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
 321
 478322        foreach (var item in valueMap)
 323        {
 120324            var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList();
 240325            foreach (var itemValue in item.Values)
 326            {
 0327                var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId);
 0328                if (existingItem is null)
 329                {
 0330                    context.ItemValuesMap.Add(new ItemValueMap()
 0331                    {
 0332                        Item = null!,
 0333                        ItemId = item.Item.Id,
 0334                        ItemValue = null!,
 0335                        ItemValueId = itemValue.ItemValueId
 0336                    });
 337                }
 338                else
 339                {
 0340                    itemMappedValues.Remove(existingItem);
 341                }
 342            }
 343
 120344            context.ItemValuesMap.RemoveRange(itemMappedValues);
 345        }
 346
 119347        var itemsWithAncestors = tuples
 119348            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 119349            .Select(t => t.Item.Id)
 119350            .ToList();
 351
 119352        var allExistingAncestorIds = itemsWithAncestors.Count > 0
 119353            ? context.AncestorIds
 119354                .Where(e => itemsWithAncestors.Contains(e.ItemId))
 119355                .ToList()
 119356                .GroupBy(e => e.ItemId)
 119357                .ToDictionary(g => g.Key, g => g.ToList())
 119358            : new Dictionary<Guid, List<AncestorId>>();
 359
 119360        var allRequestedAncestorIds = tuples
 119361            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 119362            .SelectMany(t => t.AncestorIds!)
 119363            .Distinct()
 119364            .ToList();
 365
 119366        var validAncestorIdsSet = allRequestedAncestorIds.Count > 0
 119367            ? context.BaseItems
 119368                .Where(e => allRequestedAncestorIds.Contains(e.Id))
 119369                .Select(f => f.Id)
 119370                .ToHashSet()
 119371            : new HashSet<Guid>();
 372
 478373        foreach (var item in tuples)
 374        {
 120375            if (item.Item.SupportsAncestors && item.AncestorIds != null)
 376            {
 120377                var existingAncestorIds = allExistingAncestorIds.GetValueOrDefault(item.Item.Id) ?? new List<AncestorId>
 120378                var validAncestorIds = item.AncestorIds.Where(id => validAncestorIdsSet.Contains(id)).ToArray();
 296379                foreach (var ancestorId in validAncestorIds)
 380                {
 28381                    var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId);
 28382                    if (existingAncestorId is null)
 383                    {
 24384                        context.AncestorIds.Add(new AncestorId()
 24385                        {
 24386                            ParentItemId = ancestorId,
 24387                            ItemId = item.Item.Id,
 24388                            Item = null!,
 24389                            ParentItem = null!
 24390                        });
 391                    }
 392                    else
 393                    {
 4394                        existingAncestorIds.Remove(existingAncestorId);
 395                    }
 396                }
 397
 120398                context.AncestorIds.RemoveRange(existingAncestorIds);
 399            }
 400        }
 401
 402        // Owned rows of updated items are rewritten wholesale; cleared in one statement per table.
 119403        if (existingItems.Count > 0)
 404        {
 55405            var updatedIds = existingItems.ToArray();
 55406            context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
 55407            context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
 55408            context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
 409        }
 410
 119411        context.SaveChanges();
 412
 119413        var folderIds = tuples
 119414            .Where(t => t.Item is Folder)
 119415            .Select(t => t.Item.Id)
 119416            .ToList();
 417
 119418        var videoIds = tuples
 119419            .Where(t => t.Item is Video)
 119420            .Select(t => t.Item.Id)
 119421            .ToList();
 422
 119423        var allLinkedChildrenByParent = new Dictionary<Guid, List<LinkedChildEntity>>();
 119424        if (folderIds.Count > 0 || videoIds.Count > 0)
 425        {
 115426            var allParentIds = folderIds.Concat(videoIds).Distinct().ToList();
 115427            var allLinkedChildren = context.LinkedChildren
 115428                .Where(e => allParentIds.Contains(e.ParentId))
 115429                .ToList();
 430
 115431            allLinkedChildrenByParent = allLinkedChildren
 115432                .GroupBy(e => e.ParentId)
 115433                .ToDictionary(g => g.Key, g => g.ToList());
 434        }
 435
 478436        foreach (var item in tuples)
 437        {
 438            // A container that was never hydrated cannot be used to rewrite its links: its empty
 439            // array means "unknown", so clearing the stored rows would silently empty the item.
 120440            if (item.Item is Folder { LinkedChildrenLoaded: false })
 441            {
 442                continue;
 443            }
 444
 96445            if (item.Item is Folder or Video
 96446                && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
 96447                && existingLinks.Count > 0)
 448            {
 449                // A video only owns its alternate version links; any other link on that parent is
 450                // written by the folder branch below and must survive.
 0451                var staleLinks = item.Item is Folder
 0452                    ? existingLinks
 0453                    : existingLinks
 0454                        .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAl
 0455                        .ToList();
 456
 0457                if (staleLinks.Count > 0)
 458                {
 0459                    context.LinkedChildren.RemoveRange(staleLinks);
 460                }
 461            }
 462        }
 463
 119464        context.SaveChanges();
 465
 466        // A LinkedChild's ItemId is only a cache.
 119467        var cachedChildIds = tuples
 119468            .Select(t => t.Item)
 119469            .OfType<Folder>()
 119470            .Where(f => f.LinkedChildrenLoaded)
 119471            .SelectMany(f => f.LinkedChildren)
 119472            .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
 119473            .Select(lc => lc.ItemId!.Value)
 119474            .Distinct()
 119475            .ToList();
 476
 119477        var knownChildIds = cachedChildIds.Count > 0
 119478            ? context.BaseItems
 119479                .WhereOneOrMany(cachedChildIds, e => e.Id)
 119480                .Select(e => e.Id)
 119481                .ToHashSet()
 119482            : [];
 483
 478484        foreach (var item in tuples)
 485        {
 120486            if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0)
 487            {
 488#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
 0489                var pathsToResolve = folder.LinkedChildren
 0490                    .Where(lc => !string.IsNullOrEmpty(lc.Path)
 0491                        && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)
 0492                    .Select(lc => lc.Path)
 0493                    .Distinct()
 0494                    .ToList();
 495
 0496                var pathToIdMap = pathsToResolve.Count > 0
 0497                    ? context.BaseItems
 0498                        .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 0499                        .Select(e => new { e.Path, e.Id })
 0500                        .GroupBy(e => e.Path!)
 0501                        .ToDictionary(g => g.Key, g => g.First().Id)
 0502                    : [];
 503
 0504                var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
 0505                foreach (var linkedChild in folder.LinkedChildren)
 506                {
 0507                    var childItemId = linkedChild.ItemId;
 0508                    if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Valu
 509                    {
 0510                        if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var
 511                        {
 0512                            childItemId = resolvedId;
 513                        }
 0514                        else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmp
 515                        {
 0516                            childItemId = libraryItemId;
 517                        }
 518                    }
 519#pragma warning restore CS0618
 520
 0521                    if (childItemId.HasValue && !childItemId.Value.IsEmpty())
 522                    {
 0523                        resolvedChildren.Add((linkedChild, childItemId.Value));
 524                    }
 525                }
 526
 527                // Playlists may legitimately contain the same item multiple times (e.g. a song repeated
 528                // in an .m3u file). Every other container type keeps a single entry per child.
 0529                var isPlaylist = folder is Playlist;
 0530                if (!isPlaylist)
 531                {
 0532                    resolvedChildren = resolvedChildren
 0533                        .GroupBy(c => c.ChildId)
 0534                        .Select(g => g.Last())
 0535                        .ToList();
 536                }
 537
 0538                var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
 0539                var existingChildIds = childIdsToCheck.Count > 0
 0540                    ? context.BaseItems
 0541                        .Where(e => childIdsToCheck.Contains(e.Id))
 0542                        .Select(e => e.Id)
 0543                        .ToHashSet()
 0544                    : [];
 545
 0546                var sortOrder = 0;
 0547                foreach (var (linkedChild, childId) in resolvedChildren)
 548                {
 0549                    if (!existingChildIds.Contains(childId))
 550                    {
 551#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics
 0552                        _logger.LogWarning(
 0553                            "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {Chil
 0554                            item.Item.Name,
 0555                            item.Item.Id,
 0556                            childId,
 0557                            linkedChild.Path ?? "unknown");
 558#pragma warning restore CS0618
 0559                        continue;
 560                    }
 561
 0562                    context.LinkedChildren.Add(new LinkedChildEntity()
 0563                    {
 0564                        ParentId = item.Item.Id,
 0565                        ChildId = childId,
 0566                        ChildType = (DbLinkedChildType)linkedChild.Type,
 0567                        SortOrder = sortOrder
 0568                    });
 569
 0570                    sortOrder++;
 571                }
 572            }
 573
 120574            if (item.Item is Video video)
 575            {
 0576                var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
 577
 0578                if (video.LocalAlternateVersions.Length > 0)
 579                {
 0580                    var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList();
 0581                    if (pathsToResolve.Count > 0)
 582                    {
 0583                        var pathToIdMap = context.BaseItems
 0584                            .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 0585                            .Select(e => new { e.Path, e.Id })
 0586                            .GroupBy(e => e.Path!)
 0587                            .ToDictionary(g => g.Key, g => g.First().Id);
 588
 0589                        foreach (var path in pathsToResolve)
 590                        {
 0591                            if (pathToIdMap.TryGetValue(path, out var childId))
 592                            {
 0593                                newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion));
 594                            }
 595                        }
 596                    }
 597                }
 598
 0599                if (video.LinkedAlternateVersions.Length > 0)
 600                {
 0601                    foreach (var linkedChild in video.LinkedAlternateVersions)
 602                    {
 0603                        if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty())
 604                        {
 0605                            newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion));
 606                        }
 607                    }
 608                }
 609
 610                // Deduplicate; local (file-based) relationships take priority over linked (user-merged)
 611                // ones, matching the LinkedChildren migration.
 0612                newLinkedChildren = newLinkedChildren
 0613                    .GroupBy(c => c.ChildId)
 0614                    .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
 0615                    .ToList();
 616
 0617                var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
 0618                var existingChildIds = childIdsToCheck.Count > 0
 0619                    ? context.BaseItems
 0620                        .Where(e => childIdsToCheck.Contains(e.Id))
 0621                        .Select(e => e.Id)
 0622                        .ToHashSet()
 0623                    : [];
 624
 0625                var sortOrder = 0;
 0626                foreach (var (childId, childType) in newLinkedChildren)
 627                {
 0628                    if (!existingChildIds.Contains(childId))
 629                    {
 0630                        _logger.LogWarning(
 0631                            "Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not
 0632                            video.Name,
 0633                            video.Id,
 0634                            childId);
 0635                        continue;
 636                    }
 637
 0638                    context.LinkedChildren.Add(new LinkedChildEntity
 0639                    {
 0640                        ParentId = video.Id,
 0641                        ChildId = childId,
 0642                        ChildType = (DbLinkedChildType)childType,
 0643                        SortOrder = sortOrder
 0644                    });
 645
 0646                    sortOrder++;
 647                }
 648
 649                // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
 0650                var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
 0651                if (previousLinkedChildren is { Count: > 0 })
 652                {
 0653                    var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
 0654                    var orphanedLocalVersionIds = previousLinkedChildren
 0655                        .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.Ch
 0656                        .Select(e => e.ChildId)
 0657                        .ToList();
 658
 0659                    if (orphanedLocalVersionIds.Count > 0)
 660                    {
 0661                        var orphanedItems = context.BaseItems
 0662                            .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id)
 0663                            .ToList();
 664
 0665                        if (orphanedItems.Count > 0)
 666                        {
 0667                            _logger.LogInformation(
 0668                                "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})
 0669                                orphanedItems.Count,
 0670                                video.Name,
 0671                                video.Id);
 0672                            context.BaseItems.RemoveRange(orphanedItems);
 673                        }
 674                    }
 675                }
 676            }
 677        }
 678
 119679        context.SaveChanges();
 119680        transaction.Commit();
 238681    }
 682
 683    private static List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> in
 684    {
 120685        var list = new List<(ItemValueType, string)>();
 686
 120687        if (item is IHasArtist hasArtist)
 688        {
 0689            list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i)));
 690        }
 691
 120692        if (item is IHasAlbumArtist hasAlbumArtist)
 693        {
 0694            list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i)));
 695        }
 696
 120697        list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i)));
 120698        list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i)));
 120699        list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i)));
 700
 120701        list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i)));
 702
 120703        list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
 704
 120705        return list;
 706    }
 707}