< 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
79%
Covered lines: 380
Uncovered lines: 100
Coverable lines: 480
Total lines: 816
Line coverage: 79.1%
Branch coverage
67%
Covered branches: 118
Total branches: 176
Branch coverage: 67%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 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: 70710/29/2026 - 12:17:11 AM Line coverage: 79.1% (380/480) Branch coverage: 67% (118/176) Total lines: 816 5/16/2026 - 12:15:55 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: 70710/29/2026 - 12:17:11 AM Line coverage: 79.1% (380/480) Branch coverage: 67% (118/176) Total lines: 816

Coverage delta

Coverage delta 20 -20

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
DeleteItem(...)71.42%141494.66%
UpdateInheritedValues()100%11100%
SaveItems(...)100%11100%
SaveImagesAsync()50%2295%
ReattachUserDataAsync()100%22100%
ReconcileUserDataAsync()100%44100%
UpdateOrInsertItems(...)60.66%106115065.66%
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.Globalization;
 6using System.Linq;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Jellyfin.Database.Implementations;
 10using Jellyfin.Database.Implementations.Entities;
 11using Jellyfin.Extensions;
 12using MediaBrowser.Controller;
 13using MediaBrowser.Controller.Entities;
 14using MediaBrowser.Controller.Entities.Audio;
 15using MediaBrowser.Controller.Persistence;
 16using MediaBrowser.Controller.Playlists;
 17using Microsoft.EntityFrameworkCore;
 18using Microsoft.Extensions.Logging;
 19using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
 20using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
 21using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
 22
 23namespace Jellyfin.Server.Implementations.Item;
 24
 25/// <summary>
 26/// Handles item persistence operations (save, delete, update).
 27/// </summary>
 28public class ItemPersistenceService : IItemPersistenceService
 29{
 30    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 31    private readonly IServerApplicationHost _appHost;
 32    private readonly ILogger<ItemPersistenceService> _logger;
 33
 34    /// <summary>
 35    /// Initializes a new instance of the <see cref="ItemPersistenceService"/> class.
 36    /// </summary>
 37    /// <param name="dbProvider">The database context factory.</param>
 38    /// <param name="appHost">The application host.</param>
 39    /// <param name="logger">The logger.</param>
 40    public ItemPersistenceService(
 41        IDbContextFactory<JellyfinDbContext> dbProvider,
 42        IServerApplicationHost appHost,
 43        ILogger<ItemPersistenceService> logger)
 44    {
 4845        _dbProvider = dbProvider;
 4846        _appHost = appHost;
 4847        _logger = logger;
 4848    }
 49
 50    /// <inheritdoc />
 51    public void DeleteItem(params IReadOnlyList<Guid> ids)
 52    {
 1253        if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(BaseItemRepository.PlaceholderId)))
 54        {
 055            throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids));
 56        }
 57
 1258        using var context = _dbProvider.CreateDbContext();
 1259        using var transaction = context.Database.BeginTransaction();
 60
 1261        var date = (DateTime?)DateTime.UtcNow;
 62
 1263        var descendantIds = DescendantQueryHelper.GetOwnedDescendantIdsBatch(context, ids);
 5064        foreach (var id in ids)
 65        {
 1366            descendantIds.Add(id);
 67        }
 68
 69        // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
 70        // single parameter (json_each) rather than one SQL variable per id, which would otherwise
 71        // overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
 1272        var frontier = descendantIds.ToArray();
 3273        while (frontier.Length > 0)
 74        {
 2075            var ownedIds = context.BaseItems
 2076                .Where(e => e.OwnerId.HasValue)
 2077                .WhereOneOrMany(frontier, e => e.OwnerId!.Value)
 2078                .Select(e => e.Id)
 2079                .ToArray();
 80
 2081            var childIds = context.BaseItems
 2082                .Where(e => e.ParentId.HasValue)
 2083                .WhereOneOrMany(frontier, e => e.ParentId!.Value)
 2084                .Select(e => e.Id)
 2085                .ToArray();
 86
 87            // Only ids that were not already known become the next frontier, so ownership cycles
 88            // terminate instead of looping forever.
 2089            frontier = [.. ownedIds.Concat(childIds).Where(e => descendantIds.Add(e))];
 90        }
 91
 1292        var relatedItems = descendantIds.ToArray();
 93
 94        // When batch-deleting, multiple items may have UserData for the same (UserId, CustomDataKey).
 95        // Moving all of them to PlaceholderId would violate the UNIQUE constraint.
 96        // Deduplicate by loading keys client-side, keeping the best row per group.
 1297        var batchUserData = context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId);
 98
 1299        var allRows = batchUserData
 12100            .Select(ud => new { ud.ItemId, ud.UserId, ud.CustomDataKey, ud.LastPlayedDate, ud.PlayCount })
 12101            .ToList();
 102
 12103        var duplicateRows = allRows
 12104            .GroupBy(ud => new { ud.UserId, ud.CustomDataKey })
 12105            .Where(g => g.Count() > 1)
 12106            .SelectMany(g => g
 12107                .OrderByDescending(ud => ud.LastPlayedDate)
 12108                .ThenByDescending(ud => ud.PlayCount)
 12109                .Skip(1))
 12110            .ToList();
 111
 24112        foreach (var dup in duplicateRows)
 113        {
 0114            context.UserData
 0115                .Where(ud => ud.ItemId == dup.ItemId && ud.UserId == dup.UserId && ud.CustomDataKey == dup.CustomDataKey
 0116                .ExecuteDelete();
 117        }
 118
 119        // Delete existing placeholder rows that would conflict with the incoming ones
 12120        context.UserData
 12121            .Join(
 12122                batchUserData,
 12123                placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
 12124                userData => new { userData.UserId, userData.CustomDataKey },
 12125                (placeholder, userData) => placeholder)
 12126            .Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
 12127            .ExecuteDelete();
 128
 12129        batchUserData
 12130            .ExecuteUpdate(e => e
 12131                .SetProperty(f => f.RetentionDate, date)
 12132                .SetProperty(f => f.ItemId, BaseItemRepository.PlaceholderId));
 133
 12134        context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12135        context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete();
 12136        context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12137        context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12138        context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12139        context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12140        context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12141        context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12142        context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12143        context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12144        context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 12145        context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
 12146        context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
 12147        var peopleIds = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Di
 12148        context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
 12149        context.ItemValues.Where(e => !e.BaseItemsMap!.Any()).ExecuteDelete();
 11150        context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 11151        context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 11152        context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 11153        context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 11154        context.Peoples.WhereOneOrMany(peopleIds, e => e.Id).Where(e => !e.BaseItems!.Any()).ExecuteDelete();
 11155        context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
 11156        context.SaveChanges();
 11157        transaction.Commit();
 22158    }
 159
 160    /// <inheritdoc />
 161    public void UpdateInheritedValues()
 162    {
 20163        using var context = _dbProvider.CreateDbContext();
 20164        using var transaction = context.Database.BeginTransaction();
 165
 20166        context.ItemValuesMap.Where(e => e.ItemValue.Type == ItemValueType.InheritedTags).ExecuteDelete();
 20167        context.SaveChanges();
 168
 20169        transaction.Commit();
 40170    }
 171
 172    /// <inheritdoc />
 173    public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
 174    {
 172175        UpdateOrInsertItems(items, cancellationToken);
 172176    }
 177
 178    /// <inheritdoc />
 179    public async Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default)
 180    {
 3181        ArgumentNullException.ThrowIfNull(item);
 182
 3183        var images = item.ImageInfos.Select(e => BaseItemMapper.MapImageToEntity(item.Id, e)).ToArray();
 184
 3185        var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 3186        await using (context.ConfigureAwait(false))
 187        {
 3188            await context.BaseItemImageInfos
 3189                .Where(e => e.ItemId == item.Id)
 3190                .ExecuteDeleteAsync(cancellationToken)
 3191                .ConfigureAwait(false);
 192
 3193            await context.BaseItemImageInfos
 3194                .AddRangeAsync(images, cancellationToken)
 3195                .ConfigureAwait(false);
 196
 197            try
 198            {
 3199                await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 2200            }
 1201            catch (DbUpdateException)
 202            {
 203                // Checking that the item exists before writing leaves a gap a scan can delete it
 204                // through, turning the insert into a foreign key violation that fails the whole
 205                // refresh instead of the no-op intended here. Let the insert be the check: it is the
 206                // only point at which the answer cannot go stale. Nothing is orphaned by the delete
 207                // above, because deleting the item cascades to its images anyway.
 1208                if (await context.BaseItems
 1209                    .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
 1210                    .ConfigureAwait(false))
 211                {
 0212                    throw;
 213                }
 214
 1215                _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id);
 216            }
 217        }
 3218    }
 219
 220    /// <inheritdoc />
 221    public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
 222    {
 49223        ArgumentNullException.ThrowIfNull(item);
 49224        cancellationToken.ThrowIfCancellationRequested();
 225
 48226        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 227
 48228        await using (dbContext.ConfigureAwait(false))
 229        {
 48230            var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
 48231            await using (transaction.ConfigureAwait(false))
 232            {
 48233                var userKeys = item.GetUserDataKeys().Distinct().ToList();
 234
 48235                var detached = await dbContext.UserData
 48236                    .Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
 48237                    .Where(e => userKeys.Contains(e.CustomDataKey))
 48238                    .ToListAsync(cancellationToken)
 48239                    .ConfigureAwait(false);
 240
 48241                if (detached.Count > 0)
 242                {
 2243                    await ReconcileUserDataAsync(dbContext, item, userKeys, detached, cancellationToken).ConfigureAwait(
 244                }
 245
 48246                item.UserData = await dbContext.UserData
 48247                    .AsNoTracking()
 48248                    .Where(e => e.ItemId == item.Id)
 48249                    .ToArrayAsync(cancellationToken)
 48250                    .ConfigureAwait(false);
 251
 48252                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
 48253            }
 48254        }
 48255    }
 256
 257    private static async Task ReconcileUserDataAsync(
 258        JellyfinDbContext dbContext,
 259        BaseItemDto item,
 260        IReadOnlyList<string> userKeys,
 261        List<UserData> detached,
 262        CancellationToken cancellationToken)
 263    {
 2264        var existing = await dbContext.UserData
 2265            .Where(e => e.ItemId == item.Id)
 2266            .ToListAsync(cancellationToken)
 2267            .ConfigureAwait(false);
 268
 269        // Keys the item no longer reports are carried over: they may only be missing mid-refresh.
 2270        var winners = detached.Concat(existing)
 2271            .GroupBy(e => e.UserId)
 2272            .Select(g => (
 2273                Winner: g
 2274                    .OrderByDescending(e => e.LastPlayedDate)
 2275                    .ThenByDescending(e => e.PlayCount)
 2276                    .ThenByDescending(e => e.PlaybackPositionTicks)
 2277                    .First(),
 2278                Keys: userKeys.Union(g.Select(e => e.CustomDataKey)).ToList()))
 2279            .ToList();
 280
 2281        dbContext.UserData.RemoveRange(detached);
 2282        dbContext.UserData.RemoveRange(existing);
 2283        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 284
 8285        foreach (var (winner, keys) in winners)
 286        {
 18287            foreach (var key in keys)
 288            {
 7289                dbContext.UserData.Add(new UserData
 7290                {
 7291                    ItemId = item.Id,
 7292                    Item = null,
 7293                    UserId = winner.UserId,
 7294                    User = null,
 7295                    CustomDataKey = key,
 7296                    RetentionDate = null,
 7297                    AudioStreamIndex = winner.AudioStreamIndex,
 7298                    IsFavorite = winner.IsFavorite,
 7299                    LastPlayedDate = winner.LastPlayedDate,
 7300                    Likes = winner.Likes,
 7301                    PlaybackPositionTicks = winner.PlaybackPositionTicks,
 7302                    PlayCount = winner.PlayCount,
 7303                    Played = winner.Played,
 7304                    Rating = winner.Rating,
 7305                    SubtitleStreamIndex = winner.SubtitleStreamIndex
 7306                });
 307            }
 308        }
 309
 2310        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 2311    }
 312
 313    private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
 314    {
 172315        ArgumentNullException.ThrowIfNull(items);
 172316        cancellationToken.ThrowIfCancellationRequested();
 317
 172318        var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> Use
 690319        foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != BaseItemRepository.Placeh
 320        {
 173321            var ancestorIds = item.SupportsAncestors ?
 173322                item.GetAncestorIds().Distinct().ToList() :
 173323                null;
 324
 173325            var topParent = item.GetTopParent();
 326
 173327            var userdataKey = item.GetUserDataKeys();
 173328            var inheritedTags = item.GetInheritedTags();
 329
 173330            tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags));
 331        }
 332
 172333        using var context = _dbProvider.CreateDbContext();
 172334        using var transaction = context.Database.BeginTransaction();
 335
 172336        var ids = tuples.Select(f => f.Item.Id).ToArray();
 172337        var existingItems = context.BaseItems.WhereOneOrMany(ids, e => e.Id).Select(f => f.Id).ToHashSet();
 338
 690339        foreach (var item in tuples)
 340        {
 173341            var entity = BaseItemMapper.Map(item.Item, _appHost);
 173342            entity.TopParentId = item.TopParent?.Id;
 343
 173344            if (!existingItems.Contains(entity.Id))
 345            {
 94346                context.BaseItems.Add(entity);
 347            }
 348            else
 349            {
 79350                if (entity.Images is { Count: > 0 })
 351                {
 2352                    context.BaseItemImageInfos.AddRange(entity.Images);
 353                }
 354
 79355                if (entity.LockedFields is { Count: > 0 })
 356                {
 1357                    context.BaseItemMetadataFields.AddRange(entity.LockedFields);
 358                }
 359
 79360                context.BaseItems.Attach(entity).State = EntityState.Modified;
 361            }
 362        }
 363
 172364        var itemValueMaps = tuples
 172365            .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
 172366            .ToArray();
 172367        var allListedItemValues = itemValueMaps
 172368            .SelectMany(f => f.Values)
 172369            .Distinct()
 172370            .ToArray();
 371
 172372        var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray();
 172373        var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray();
 172374        var allListedItemValuesSet = allListedItemValues.ToHashSet();
 375
 172376        var existingValues = context.ItemValues
 172377            .Where(e => types.Contains(e.Type) && values.Contains(e.Value))
 172378            .AsEnumerable()
 172379            .Where(e => allListedItemValuesSet.Contains((e.Type, e.Value)))
 172380            .ToArray();
 172381        var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).S
 172382        {
 172383            CleanValue = f.Value.GetCleanValue(),
 172384            ItemValueId = Guid.NewGuid(),
 172385            Type = f.MagicNumber,
 172386            Value = f.Value
 172387        }).ToArray();
 172388        context.ItemValues.AddRange(missingItemValues);
 389
 172390        var itemValuesStore = existingValues
 172391            .Concat(missingItemValues)
 172392            .ToDictionary(e => (e.Type, e.Value));
 172393        var valueMap = itemValueMaps
 172394            .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e =
 172395            .ToArray();
 396
 172397        var mappedValues = context.ItemValuesMap.WhereOneOrMany(ids, e => e.ItemId).ToList();
 398
 690399        foreach (var item in valueMap)
 400        {
 173401            var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList();
 346402            foreach (var itemValue in item.Values)
 403            {
 0404                var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId);
 0405                if (existingItem is null)
 406                {
 0407                    context.ItemValuesMap.Add(new ItemValueMap()
 0408                    {
 0409                        Item = null!,
 0410                        ItemId = item.Item.Id,
 0411                        ItemValue = null!,
 0412                        ItemValueId = itemValue.ItemValueId
 0413                    });
 414                }
 415                else
 416                {
 0417                    itemMappedValues.Remove(existingItem);
 418                }
 419            }
 420
 173421            context.ItemValuesMap.RemoveRange(itemMappedValues);
 422        }
 423
 172424        var itemsWithAncestors = tuples
 172425            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 172426            .Select(t => t.Item.Id)
 172427            .ToList();
 428
 172429        var allExistingAncestorIds = itemsWithAncestors.Count > 0
 172430            ? context.AncestorIds
 172431                .Where(e => itemsWithAncestors.Contains(e.ItemId))
 172432                .ToList()
 172433                .GroupBy(e => e.ItemId)
 172434                .ToDictionary(g => g.Key, g => g.ToList())
 172435            : new Dictionary<Guid, List<AncestorId>>();
 436
 172437        var allRequestedAncestorIds = tuples
 172438            .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null)
 172439            .SelectMany(t => t.AncestorIds!)
 172440            .Distinct()
 172441            .ToList();
 442
 172443        var validAncestorIdsSet = allRequestedAncestorIds.Count > 0
 172444            ? context.BaseItems
 172445                .Where(e => allRequestedAncestorIds.Contains(e.Id))
 172446                .Select(f => f.Id)
 172447                .ToHashSet()
 172448            : new HashSet<Guid>();
 449
 690450        foreach (var item in tuples)
 451        {
 173452            if (item.Item.SupportsAncestors && item.AncestorIds != null)
 453            {
 173454                var existingAncestorIds = allExistingAncestorIds.GetValueOrDefault(item.Item.Id) ?? new List<AncestorId>
 173455                var validAncestorIds = item.AncestorIds.Where(id => validAncestorIdsSet.Contains(id)).ToArray();
 460456                foreach (var ancestorId in validAncestorIds)
 457                {
 57458                    var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId);
 57459                    if (existingAncestorId is null)
 460                    {
 36461                        context.AncestorIds.Add(new AncestorId()
 36462                        {
 36463                            ParentItemId = ancestorId,
 36464                            ItemId = item.Item.Id,
 36465                            Item = null!,
 36466                            ParentItem = null!
 36467                        });
 468                    }
 469                    else
 470                    {
 21471                        existingAncestorIds.Remove(existingAncestorId);
 472                    }
 473                }
 474
 173475                context.AncestorIds.RemoveRange(existingAncestorIds);
 476            }
 477        }
 478
 479        // Owned rows of updated items are rewritten wholesale; cleared in one statement per table.
 172480        if (existingItems.Count > 0)
 481        {
 79482            var updatedIds = existingItems.ToArray();
 79483            context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
 79484            context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
 79485            context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
 486        }
 487
 172488        context.SaveChanges();
 489
 172490        var folderIds = tuples
 172491            .Where(t => t.Item is Folder)
 172492            .Select(t => t.Item.Id)
 172493            .ToList();
 494
 172495        var videoIds = tuples
 172496            .Where(t => t.Item is Video)
 172497            .Select(t => t.Item.Id)
 172498            .ToList();
 499
 172500        var allLinkedChildrenByParent = new Dictionary<Guid, List<LinkedChildEntity>>();
 172501        if (folderIds.Count > 0 || videoIds.Count > 0)
 502        {
 168503            var allParentIds = folderIds.Concat(videoIds).Distinct().ToList();
 168504            var allLinkedChildren = context.LinkedChildren
 168505                .Where(e => allParentIds.Contains(e.ParentId))
 168506                .ToList();
 507
 168508            allLinkedChildrenByParent = allLinkedChildren
 168509                .GroupBy(e => e.ParentId)
 168510                .ToDictionary(g => g.Key, g => g.ToList());
 511        }
 512
 690513        foreach (var item in tuples)
 514        {
 515            // A container that was never hydrated cannot be used to rewrite its links: its empty
 516            // array means "unknown", so clearing the stored rows would silently empty the item.
 173517            if (item.Item is Folder { LinkedChildrenLoaded: false })
 518            {
 519                continue;
 520            }
 521
 124522            if (item.Item is Folder or Video
 124523                && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
 124524                && existingLinks.Count > 0)
 525            {
 526                // A video only owns its alternate version links; any other link on that parent is
 527                // written by the folder branch below and must survive.
 0528                var staleLinks = item.Item is Folder
 0529                    ? existingLinks
 0530                    : existingLinks
 0531                        .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAl
 0532                        .ToList();
 533
 0534                if (staleLinks.Count > 0)
 535                {
 0536                    context.LinkedChildren.RemoveRange(staleLinks);
 537                }
 538            }
 539        }
 540
 172541        context.SaveChanges();
 542
 543        // A LinkedChild's ItemId is only a cache.
 172544        var cachedChildIds = tuples
 172545            .Select(t => t.Item)
 172546            .OfType<Folder>()
 172547            .Where(f => f.LinkedChildrenLoaded)
 172548            .SelectMany(f => f.LinkedChildren)
 172549            .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
 172550            .Select(lc => lc.ItemId!.Value)
 172551            .Distinct()
 172552            .ToList();
 553
 172554        var knownChildIds = cachedChildIds.Count > 0
 172555            ? context.BaseItems
 172556                .WhereOneOrMany(cachedChildIds, e => e.Id)
 172557                .Select(e => e.Id)
 172558                .ToHashSet()
 172559            : [];
 560
 690561        foreach (var item in tuples)
 562        {
 173563            if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0)
 564            {
 565#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
 0566                var pathsToResolve = folder.LinkedChildren
 0567                    .Where(lc => !string.IsNullOrEmpty(lc.Path)
 0568                        && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)
 0569                    .Select(lc => lc.Path)
 0570                    .Distinct()
 0571                    .ToList();
 572
 0573                var pathToIdMap = pathsToResolve.Count > 0
 0574                    ? context.BaseItems
 0575                        .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 0576                        .Select(e => new { e.Path, e.Id })
 0577                        .GroupBy(e => e.Path!)
 0578                        .ToDictionary(g => g.Key, g => g.First().Id)
 0579                    : [];
 580
 0581                var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
 0582                foreach (var linkedChild in folder.LinkedChildren)
 583                {
 0584                    var childItemId = linkedChild.ItemId;
 0585                    if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Valu
 586                    {
 0587                        if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var
 588                        {
 0589                            childItemId = resolvedId;
 590                        }
 0591                        else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmp
 592                        {
 0593                            childItemId = libraryItemId;
 594                        }
 595                    }
 596#pragma warning restore CS0618
 597
 0598                    if (childItemId.HasValue && !childItemId.Value.IsEmpty())
 599                    {
 0600                        resolvedChildren.Add((linkedChild, childItemId.Value));
 601                    }
 602                }
 603
 604                // Playlists may legitimately contain the same item multiple times (e.g. a song repeated
 605                // in an .m3u file). Every other container type keeps a single entry per child.
 0606                var isPlaylist = folder is Playlist;
 0607                if (!isPlaylist)
 608                {
 0609                    resolvedChildren = resolvedChildren
 0610                        .GroupBy(c => c.ChildId)
 0611                        .Select(g => g.Last())
 0612                        .ToList();
 613                }
 614
 0615                var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
 0616                var existingChildIds = childIdsToCheck.Count > 0
 0617                    ? context.BaseItems
 0618                        .WhereOneOrMany(childIdsToCheck, e => e.Id)
 0619                        .Select(e => e.Id)
 0620                        .ToHashSet()
 0621                    : [];
 622
 0623                var sortOrder = 0;
 0624                foreach (var (linkedChild, childId) in resolvedChildren)
 625                {
 0626                    if (!existingChildIds.Contains(childId))
 627                    {
 628#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics
 0629                        _logger.LogWarning(
 0630                            "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {Chil
 0631                            item.Item.Name,
 0632                            item.Item.Id,
 0633                            childId,
 0634                            linkedChild.Path ?? "unknown");
 635#pragma warning restore CS0618
 0636                        continue;
 637                    }
 638
 0639                    context.LinkedChildren.Add(new LinkedChildEntity()
 0640                    {
 0641                        ParentId = item.Item.Id,
 0642                        ChildId = childId,
 0643                        ChildType = (DbLinkedChildType)linkedChild.Type,
 0644                        SortOrder = sortOrder
 0645                    });
 646
 0647                    sortOrder++;
 648                }
 649            }
 650
 173651            if (item.Item is Video video)
 652            {
 9653                var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
 654
 9655                if (video.LocalAlternateVersions.Length > 0)
 656                {
 4657                    var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList();
 4658                    if (pathsToResolve.Count > 0)
 659                    {
 4660                        var pathToIdMap = context.BaseItems
 4661                            .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
 4662                            .Select(e => new { e.Path, e.Id })
 4663                            .GroupBy(e => e.Path!)
 4664                            .ToDictionary(g => g.Key, g => g.First().Id);
 665
 16666                        foreach (var path in pathsToResolve)
 667                        {
 4668                            if (pathToIdMap.TryGetValue(path, out var childId))
 669                            {
 4670                                newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion));
 671                            }
 672                        }
 673                    }
 674                }
 675
 9676                if (video.LinkedAlternateVersions.Length > 0)
 677                {
 4678                    foreach (var linkedChild in video.LinkedAlternateVersions)
 679                    {
 1680                        if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty())
 681                        {
 1682                            newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion));
 683                        }
 684                    }
 685                }
 686
 687                // Deduplicate; local (file-based) relationships take priority over linked (user-merged)
 688                // ones, matching the LinkedChildren migration.
 9689                newLinkedChildren = newLinkedChildren
 9690                    .GroupBy(c => c.ChildId)
 9691                    .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
 9692                    .ToList();
 693
 9694                var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
 9695                var existingChildIds = childIdsToCheck.Count > 0
 9696                    ? context.BaseItems
 9697                        .Where(e => childIdsToCheck.Contains(e.Id))
 9698                        .Select(e => e.Id)
 9699                        .ToHashSet()
 9700                    : [];
 701
 9702                var sortOrder = 0;
 28703                foreach (var (childId, childType) in newLinkedChildren)
 704                {
 5705                    if (!existingChildIds.Contains(childId))
 706                    {
 0707                        _logger.LogWarning(
 0708                            "Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not
 0709                            video.Name,
 0710                            video.Id,
 0711                            childId);
 0712                        continue;
 713                    }
 714
 5715                    context.LinkedChildren.Add(new LinkedChildEntity
 5716                    {
 5717                        ParentId = video.Id,
 5718                        ChildId = childId,
 5719                        ChildType = (DbLinkedChildType)childType,
 5720                        SortOrder = sortOrder
 5721                    });
 722
 5723                    sortOrder++;
 724                }
 725
 9726                var linkedChildIds = newLinkedChildren
 9727                    .Select(c => c.ChildId)
 9728                    // A video listed among its own versions would be pointed at itself.
 9729                    .Where(childId => existingChildIds.Contains(childId) && !childId.Equals(video.Id))
 9730                    .Where(childId => !childId.Equals(video.PrimaryVersionId))
 9731                    .ToList();
 9732                if (linkedChildIds.Count > 0)
 733                {
 3734                    var demotedChildren = context.BaseItems
 3735                        .Where(e => linkedChildIds.Contains(e.Id)
 3736                            && (e.PrimaryVersionId == null || e.PrimaryVersionId != video.Id))
 3737                        .ToList();
 738
 10739                    foreach (var child in demotedChildren)
 740                    {
 2741                        child.PrimaryVersionId = video.Id;
 742
 743                        // Mirrors Video.CreatePresentationUniqueKey, so presentation-key grouping
 744                        // collapses the version onto its primary as well.
 2745                        child.PresentationUniqueKey = video.Id.ToString("N", CultureInfo.InvariantCulture);
 746                    }
 747
 3748                    if (demotedChildren.Count > 0)
 749                    {
 2750                        _logger.LogInformation(
 2751                            "Set PrimaryVersionId on {Count} alternate versions of video {VideoName} ({VideoId})",
 2752                            demotedChildren.Count,
 2753                            video.Name,
 2754                            video.Id);
 755                    }
 756                }
 757
 758                // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
 9759                var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
 9760                if (previousLinkedChildren is { Count: > 0 })
 761                {
 0762                    var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
 0763                    var orphanedLocalVersionIds = previousLinkedChildren
 0764                        .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.Ch
 0765                        .Select(e => e.ChildId)
 0766                        .ToList();
 767
 0768                    if (orphanedLocalVersionIds.Count > 0)
 769                    {
 0770                        var orphanedItems = context.BaseItems
 0771                            .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id)
 0772                            .ToList();
 773
 0774                        if (orphanedItems.Count > 0)
 775                        {
 0776                            _logger.LogInformation(
 0777                                "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})
 0778                                orphanedItems.Count,
 0779                                video.Name,
 0780                                video.Id);
 0781                            context.BaseItems.RemoveRange(orphanedItems);
 782                        }
 783                    }
 784                }
 785            }
 786        }
 787
 172788        context.SaveChanges();
 172789        transaction.Commit();
 344790    }
 791
 792    private static List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> in
 793    {
 173794        var list = new List<(ItemValueType, string)>();
 795
 173796        if (item is IHasArtist hasArtist)
 797        {
 0798            list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i)));
 799        }
 800
 173801        if (item is IHasAlbumArtist hasAlbumArtist)
 802        {
 0803            list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i)));
 804        }
 805
 173806        list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i)));
 173807        list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i)));
 173808        list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i)));
 809
 173810        list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i)));
 811
 173812        list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
 813
 173814        return list;
 815    }
 816}