< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.ConsolidateLocalizedUserViews
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
Line coverage
86%
Covered lines: 150
Uncovered lines: 24
Coverable lines: 174
Total lines: 362
Line coverage: 86.2%
Branch coverage
52%
Covered branches: 19
Total branches: 36
Branch coverage: 52.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/29/2026 - 12:17:11 AM Line coverage: 86.2% (150/174) Branch coverage: 52.7% (19/36) Total lines: 362 10/29/2026 - 12:17:11 AM Line coverage: 86.2% (150/174) Branch coverage: 52.7% (19/36) Total lines: 362

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
PerformAsync()87.5%8895.65%
ConsolidateAsync()100%22100%
MoveRemainingReferencesAsync()100%11100%
PickSourceAsync()100%11100%
MoveUserSettingsAsync()27.27%722253.06%
MoveAncestorsAsync()100%44100%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Jellyfin.Data.Enums;
 9using Jellyfin.Database.Implementations;
 10using Jellyfin.Database.Implementations.Entities;
 11using Jellyfin.Database.Implementations.Enums;
 12using Jellyfin.Server.ServerSetupApp;
 13using MediaBrowser.Controller.Configuration;
 14using MediaBrowser.Controller.Entities;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Model.IO;
 17using Microsoft.EntityFrameworkCore;
 18using Microsoft.Extensions.Logging;
 19
 20namespace Jellyfin.Server.Migrations.Routines;
 21
 22/// <summary>
 23/// Moves the views whose id used to be derived from their localized name onto their name independent id.
 24/// </summary>
 25[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))]
 26[JellyfinMigrationBackup(JellyfinDb = true)]
 27internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
 28{
 29    private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger;
 30    private readonly ILibraryManager _libraryManager;
 31    private readonly IServerConfigurationManager _configurationManager;
 32    private readonly IFileSystem _fileSystem;
 33    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 34
 35    /// <summary>
 36    /// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class.
 37    /// </summary>
 38    /// <param name="logger">The startup logger.</param>
 39    /// <param name="libraryManager">The library manager.</param>
 40    /// <param name="configurationManager">The server configuration manager.</param>
 41    /// <param name="fileSystem">The file system.</param>
 42    /// <param name="dbProvider">The database context factory.</param>
 43    public ConsolidateLocalizedUserViews(
 44        IStartupLogger<ConsolidateLocalizedUserViews> logger,
 45        ILibraryManager libraryManager,
 46        IServerConfigurationManager configurationManager,
 47        IFileSystem fileSystem,
 48        IDbContextFactory<JellyfinDbContext> dbProvider)
 49    {
 350        _logger = logger;
 351        _libraryManager = libraryManager;
 352        _configurationManager = configurationManager;
 353        _fileSystem = fileSystem;
 354        _dbProvider = dbProvider;
 355    }
 56
 57    /// <inheritdoc />
 58    public async Task PerformAsync(CancellationToken cancellationToken)
 59    {
 60        // The Live TV view is the one that hurts: every channel and program is parented to it, so a
 61        // translation update or a change of UI culture used to leave them behind under a view nothing
 62        // looks up any more.
 363        var views = _libraryManager.GetItemList(new InternalItemsQuery
 364        {
 365            IncludeItemTypes = [BaseItemKind.UserView]
 366        }).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray();
 67
 368        if (views.Length == 0)
 69        {
 070            return;
 71        }
 72
 373        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 374        await using (dbContext.ConfigureAwait(false))
 75        {
 1276            foreach (var group in views.GroupBy(view => view.ViewType!.Value))
 77            {
 378                cancellationToken.ThrowIfCancellationRequested();
 79
 380                var viewType = group.Key;
 381                var folderName = _fileSystem.GetValidFilename(viewType.ToString());
 382                var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName
 83
 84                // Only the views created for a view type as a whole are named after it. The per user and
 85                // per parent ones get a folder of their own, and carry no children to lose. Match on the
 86                // folder rather than the whole path so a metadata directory that has since moved still
 87                // lines up.
 388                var candidates = group
 389                    .Where(view => !string.IsNullOrEmpty(view.Path)
 390                        && string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, S
 391                    .ToArray();
 392                if (candidates.Length == 0)
 93                {
 94                    continue;
 95                }
 96
 97                // Mirrors LibraryManager.GetNamedView(name, viewType, sortName).
 398                var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserVi
 99
 3100                var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray();
 3101                if (stale.Length == 0)
 102                {
 103                    continue;
 104                }
 105
 3106                await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).Con
 107            }
 108        }
 3109    }
 110
 111    private async Task ConsolidateAsync(
 112        JellyfinDbContext dbContext,
 113        CollectionType viewType,
 114        string path,
 115        Guid canonicalId,
 116        IReadOnlyList<UserView> candidates,
 117        IReadOnlyList<UserView> stale,
 118        CancellationToken cancellationToken)
 119    {
 3120        var staleIds = stale.Select(view => view.Id).ToArray();
 3121        Guid? newParentId = canonicalId;
 3122        var sourceId = Guid.Empty;
 123
 3124        if (!candidates.Any(view => view.Id.Equals(canonicalId)))
 125        {
 126            // Whichever of the old views the items ended up under is the one worth keeping, so give the
 127            // canonical id a copy of it.
 3128            var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false);
 3129            sourceId = source.Id;
 130
 3131            _libraryManager.CreateItem(
 3132                new UserView
 3133                {
 3134                    Path = path,
 3135                    Id = canonicalId,
 3136                    DateCreated = source.DateCreated,
 3137                    DateModified = source.DateModified,
 3138                    Name = source.Name,
 3139                    ViewType = viewType,
 3140                    ForcedSortName = source.ForcedSortName
 3141                },
 3142                null);
 143        }
 144
 3145        var reparented = await dbContext.BaseItems
 3146            .Where(e => e.ParentId.HasValue)
 3147            .WhereOneOrMany(staleIds, e => e.ParentId!.Value)
 3148            .ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken)
 3149            .ConfigureAwait(false);
 150
 3151        await dbContext.BaseItems
 3152            .Where(e => e.TopParentId.HasValue)
 3153            .WhereOneOrMany(staleIds, e => e.TopParentId!.Value)
 3154            .ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken)
 3155            .ConfigureAwait(false);
 156
 3157        await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false);
 3158        await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false)
 3159        await MoveRemainingReferencesAsync(dbContext, newParentId, staleIds, cancellationToken).ConfigureAwait(false);
 160
 161        // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last.
 3162        await dbContext.BaseItems
 3163            .WhereOneOrMany(staleIds, e => e.Id)
 3164            .ExecuteDeleteAsync(cancellationToken)
 3165            .ConfigureAwait(false);
 166
 3167        _logger.LogInformation(
 3168            "Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}",
 3169            reparented,
 3170            staleIds.Length,
 3171            viewType,
 3172            canonicalId);
 3173    }
 174
 175    private static async Task MoveRemainingReferencesAsync(
 176        JellyfinDbContext dbContext,
 177        Guid? canonicalId,
 178        IReadOnlyList<Guid> staleIds,
 179        CancellationToken cancellationToken)
 180    {
 3181        await dbContext.BaseItems
 3182            .Where(e => e.OwnerId.HasValue)
 3183            .WhereOneOrMany(staleIds, e => e.OwnerId!.Value)
 3184            .ExecuteUpdateAsync(e => e.SetProperty(f => f.OwnerId, canonicalId), cancellationToken)
 3185            .ConfigureAwait(false);
 186
 187        // Keyed by (ParentId, SortOrder), so these cannot be repointed onto the canonical view
 188        // without risking a collision, and a view listing linked children is meaningless anyway.
 3189        await dbContext.LinkedChildren
 3190            .WhereOneOrMany(staleIds, e => e.ParentId)
 3191            .ExecuteDeleteAsync(cancellationToken)
 3192            .ConfigureAwait(false);
 193
 3194        await dbContext.LinkedChildren
 3195            .WhereOneOrMany(staleIds, e => e.ChildId)
 3196            .ExecuteDeleteAsync(cancellationToken)
 3197            .ConfigureAwait(false);
 3198    }
 199
 200    private async Task<UserView> PickSourceAsync(
 201        JellyfinDbContext dbContext,
 202        IReadOnlyList<UserView> stale,
 203        IReadOnlyList<Guid> staleIds,
 204        CancellationToken cancellationToken)
 205    {
 3206        var childCounts = await dbContext.BaseItems
 3207            .Where(e => e.ParentId.HasValue)
 3208            .WhereOneOrMany(staleIds, e => e.ParentId!.Value)
 3209            .GroupBy(e => e.ParentId!.Value)
 3210            .Select(g => new { ParentId = g.Key, Count = g.Count() })
 3211            .ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken)
 3212            .ConfigureAwait(false);
 213
 3214        return stale
 3215            .OrderByDescending(view => childCounts.GetValueOrDefault(view.Id))
 3216            .ThenBy(view => view.DateCreated)
 3217            .First();
 3218    }
 219
 220    private static async Task MoveUserSettingsAsync(
 221        JellyfinDbContext dbContext,
 222        Guid canonicalId,
 223        Guid sourceId,
 224        IReadOnlyList<Guid> staleIds,
 225        CancellationToken cancellationToken)
 226    {
 227        // Everything below is keyed by the view's id, and a view holding no children still holds the
 228        // ordering it was given and whether it was hidden. Only the view that was promoted can hand
 229        // those over - the rest would collide on the one row per user, item and client - so the others
 230        // are dropped instead.
 3231        var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray();
 232
 3233        if (!sourceId.Equals(Guid.Empty))
 234        {
 3235            var moved = new[] { sourceId };
 236
 3237            await dbContext.DisplayPreferences
 3238                .WhereOneOrMany(moved, e => e.ItemId)
 3239                .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
 3240                .ConfigureAwait(false);
 241
 3242            await dbContext.ItemDisplayPreferences
 3243                .WhereOneOrMany(moved, e => e.ItemId)
 3244                .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
 3245                .ConfigureAwait(false);
 246
 3247            await dbContext.CustomItemDisplayPreferences
 3248                .WhereOneOrMany(moved, e => e.ItemId)
 3249                .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
 3250                .ConfigureAwait(false);
 3251        }
 252
 3253        if (dropped.Length > 0)
 254        {
 0255            await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationTok
 0256            await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellatio
 0257            await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cance
 258        }
 259
 3260        var stale = staleIds.ToHashSet();
 3261        var preferences = await dbContext.Preferences
 3262            .Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes)
 3263            .ToListAsync(cancellationToken)
 3264            .ConfigureAwait(false);
 265
 3266        var changed = false;
 267
 6268        foreach (var preference in preferences)
 269        {
 0270            var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntr
 0271            var rewritten = new List<string>(values.Length);
 0272            var seen = new HashSet<Guid>();
 0273            var touched = false;
 274
 0275            foreach (var value in values)
 276            {
 277                // Clients write these in both the dashed and the plain form, so compare them parsed.
 0278                if (!Guid.TryParse(value, out var parsed))
 279                {
 0280                    rewritten.Add(value);
 0281                    continue;
 282                }
 283
 0284                var isStale = stale.Contains(parsed);
 0285                if (isStale)
 286                {
 0287                    parsed = canonicalId;
 0288                    touched = true;
 289                }
 290
 291                // The same view can be listed twice once both of its ids point at the same place.
 0292                if (!seen.Add(parsed))
 293                {
 294                    continue;
 295                }
 296
 0297                rewritten.Add(isStale
 0298                    ? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCu
 0299                    : value);
 300            }
 301
 0302            if (!touched)
 303            {
 304                continue;
 305            }
 306
 0307            preference.Value = string.Join(',', rewritten);
 0308            changed = true;
 309        }
 310
 3311        if (changed)
 312        {
 0313            await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 314        }
 3315    }
 316
 317    private static async Task MoveAncestorsAsync(
 318        JellyfinDbContext dbContext,
 319        Guid canonicalId,
 320        IReadOnlyList<Guid> staleIds,
 321        CancellationToken cancellationToken)
 322    {
 323        // Ancestry recorded against items that no longer exist is dead weight.
 3324        var items = await dbContext.AncestorIds
 3325            .WhereOneOrMany(staleIds, e => e.ParentItemId)
 3326            .Where(e => dbContext.BaseItems.Any(item => item.Id.Equals(e.ItemId)))
 3327            .Select(e => e.ItemId)
 3328            .Distinct()
 3329            .ToListAsync(cancellationToken)
 3330            .ConfigureAwait(false);
 331
 3332        await dbContext.AncestorIds
 3333            .WhereOneOrMany(staleIds, e => e.ParentItemId)
 3334            .ExecuteDeleteAsync(cancellationToken)
 3335            .ConfigureAwait(false);
 336
 3337        if (items.Count == 0)
 338        {
 2339            return;
 340        }
 341
 342        // The pair is the primary key, so anything already recorded against the canonical view stays put.
 1343        var existing = await dbContext.AncestorIds
 1344            .Where(e => e.ParentItemId.Equals(canonicalId))
 1345            .Select(e => e.ItemId)
 1346            .ToListAsync(cancellationToken)
 1347            .ConfigureAwait(false);
 348
 4349        foreach (var itemId in items.Except(existing))
 350        {
 1351            dbContext.AncestorIds.Add(new AncestorId
 1352            {
 1353                ItemId = itemId,
 1354                ParentItemId = canonicalId,
 1355                Item = null!,
 1356                ParentItem = null!
 1357            });
 358        }
 359
 1360        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 3361    }
 362}