< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.MigrateLibraryDb
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 611
Coverable lines: 611
Total lines: 1388
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 340
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
Perform()0%1482380%
GetPreparedDbContext(...)100%210%
GetUserData(...)0%156120%
GetAncestorId(...)100%210%
GetChapter(...)0%4260%
GetItemValue(...)100%210%
GetPerson(...)0%620%
GetMediaStream(...)0%5256720%
GetMediaAttachment(...)0%110100%
GetItem(...)0%226501500%
Map(...)0%620%
DeserializeImages(...)0%110100%
ItemImageInfoFromValueString(...)0%1190340%
.ctor(...)100%210%
get_Disposed()100%210%
set_Disposed(...)100%210%
Dispose()0%620%
.ctor(...)100%210%
Dispose()0%620%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Collections.Immutable;
 6using System.Data;
 7using System.Diagnostics;
 8using System.Globalization;
 9using System.IO;
 10using System.Linq;
 11using System.Text;
 12using Emby.Server.Implementations.Data;
 13using Jellyfin.Database.Implementations;
 14using Jellyfin.Database.Implementations.Entities;
 15using Jellyfin.Extensions;
 16using Jellyfin.Server.Implementations.Item;
 17using Jellyfin.Server.ServerSetupApp;
 18using MediaBrowser.Controller;
 19using MediaBrowser.Controller.Entities;
 20using MediaBrowser.Model.Entities;
 21using Microsoft.Data.Sqlite;
 22using Microsoft.EntityFrameworkCore;
 23using Microsoft.Extensions.Logging;
 24using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity;
 25using Chapter = Jellyfin.Database.Implementations.Entities.Chapter;
 26
 27namespace Jellyfin.Server.Migrations.Routines;
 28
 29/// <summary>
 30/// The migration routine for migrating the userdata database to EF Core.
 31/// </summary>
 32[JellyfinMigration("2025-04-20T20:00:00", nameof(MigrateLibraryDb))]
 33[JellyfinMigrationBackup(JellyfinDb = true, LegacyLibraryDb = true)]
 34internal class MigrateLibraryDb : IDatabaseMigrationRoutine
 35{
 36    private const string DbFilename = "library.db";
 37
 38    private readonly IStartupLogger _logger;
 39    private readonly IServerApplicationPaths _paths;
 40    private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
 41    private readonly IDbContextFactory<JellyfinDbContext> _provider;
 42
 43    /// <summary>
 44    /// Initializes a new instance of the <see cref="MigrateLibraryDb"/> class.
 45    /// </summary>
 46    /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
 47    /// <param name="provider">The database provider.</param>
 48    /// <param name="paths">The server application paths.</param>
 49    /// <param name="jellyfinDatabaseProvider">The database provider for special access.</param>
 50    public MigrateLibraryDb(
 51        IStartupLogger<MigrateLibraryDb> startupLogger,
 52        IDbContextFactory<JellyfinDbContext> provider,
 53        IServerApplicationPaths paths,
 54        IJellyfinDatabaseProvider jellyfinDatabaseProvider)
 55    {
 056        _logger = startupLogger;
 057        _provider = provider;
 058        _paths = paths;
 059        _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
 060    }
 61
 62    /// <inheritdoc/>
 63    public void Perform()
 64    {
 065        _logger.LogInformation("Migrating the userdata from library.db may take a while, do not stop Jellyfin.");
 66
 067        var dataPath = _paths.DataPath;
 068        var libraryDbPath = Path.Combine(dataPath, DbFilename);
 069        if (!File.Exists(libraryDbPath))
 70        {
 071            _logger.LogError("Cannot migrate {LibraryDb} as it does not exist..", libraryDbPath);
 072            return;
 73        }
 74
 075        using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
 76
 077        var fullOperationTimer = new Stopwatch();
 078        fullOperationTimer.Start();
 79
 080        using (var operation = GetPreparedDbContext("Cleanup database"))
 81        {
 082            operation.JellyfinDbContext.AttachmentStreamInfos.ExecuteDelete();
 083            operation.JellyfinDbContext.BaseItems.ExecuteDelete();
 084            operation.JellyfinDbContext.ItemValues.ExecuteDelete();
 085            operation.JellyfinDbContext.UserData.ExecuteDelete();
 086            operation.JellyfinDbContext.MediaStreamInfos.ExecuteDelete();
 087            operation.JellyfinDbContext.Peoples.ExecuteDelete();
 088            operation.JellyfinDbContext.PeopleBaseItemMap.ExecuteDelete();
 089            operation.JellyfinDbContext.Chapters.ExecuteDelete();
 090            operation.JellyfinDbContext.AncestorIds.ExecuteDelete();
 091        }
 92
 093        var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
 094        connection.Open();
 95
 096        var baseItemIds = new HashSet<Guid>();
 097        using (var operation = GetPreparedDbContext("Moving TypedBaseItem"))
 98        {
 99            const string typedBaseItemsQuery =
 100            """
 101            SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie,
 102            IsSeries, EpisodeTitle, IsRepeat, CommunityRating, CustomRating, IndexNumber, IsLocked, PreferredMetadataLan
 103            PreferredMetadataCountryCode, Width, Height, DateLastRefreshed, Name, Path, PremiereDate, Overview, ParentIn
 104            ProductionYear, OfficialRating, ForcedSortName, RunTimeTicks, Size, DateCreated, DateModified, Genres, Paren
 105            Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, Origina
 106            DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, Se
 107            PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, Product
 108            ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortN
 109            """;
 0110            using (new TrackedMigrationStep("Loading TypedBaseItems", _logger))
 111            {
 0112                foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery))
 113                {
 0114                    var baseItem = GetItem(dto);
 0115                    operation.JellyfinDbContext.BaseItems.Add(baseItem.BaseItem);
 0116                    baseItemIds.Add(baseItem.BaseItem.Id);
 0117                    foreach (var dataKey in baseItem.LegacyUserDataKey)
 118                    {
 0119                        legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem;
 120                    }
 121                }
 122            }
 123
 0124            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entrie
 125            {
 0126                operation.JellyfinDbContext.SaveChanges();
 0127            }
 128        }
 129
 0130        using (var operation = GetPreparedDbContext("Moving ItemValues"))
 131        {
 132            // do not migrate inherited types as they are now properly mapped in search and lookup.
 133            const string itemValueQuery =
 134            """
 135            SELECT ItemId, Type, Value, CleanValue FROM ItemValues
 136                        WHERE Type <> 6 AND EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = ItemValues.I
 137            """;
 138
 139            // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow.
 0140            var localItems = new Dictionary<(int Type, string Value), (Database.Implementations.Entities.ItemValue ItemV
 0141            using (new TrackedMigrationStep("Loading ItemValues", _logger))
 142            {
 0143                foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
 144                {
 0145                    var itemId = dto.GetGuid(0);
 0146                    var entity = GetItemValue(dto);
 0147                    var key = ((int)entity.Type, entity.Value);
 0148                    if (!localItems.TryGetValue(key, out var existing))
 149                    {
 0150                        localItems[key] = existing = (entity, []);
 151                    }
 152
 0153                    existing.ItemIds.Add(itemId);
 154                }
 155
 0156                foreach (var item in localItems)
 157                {
 0158                    operation.JellyfinDbContext.ItemValues.Add(item.Value.ItemValue);
 0159                    operation.JellyfinDbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new Ite
 0160                    {
 0161                        Item = null!,
 0162                        ItemValue = null!,
 0163                        ItemId = f,
 0164                        ItemValueId = item.Value.ItemValue.ItemValueId
 0165                    }));
 166                }
 167            }
 168
 0169            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues ent
 170            {
 0171                operation.JellyfinDbContext.SaveChanges();
 0172            }
 173        }
 174
 0175        using (var operation = GetPreparedDbContext("Moving UserData"))
 176        {
 0177            var queryResult = connection.Query(
 0178            """
 0179            SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStrea
 0180
 0181            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
 0182            """);
 183
 0184            using (new TrackedMigrationStep("Loading UserData", _logger))
 185            {
 0186                var users = operation.JellyfinDbContext.Users.AsNoTracking().ToArray();
 0187                var userIdBlacklist = new HashSet<int>();
 188
 0189                foreach (var entity in queryResult)
 190                {
 0191                    var userData = GetUserData(users, entity, userIdBlacklist, _logger);
 0192                    if (userData is null)
 193                    {
 0194                        var userDataId = entity.GetString(0);
 0195                        var internalUserId = entity.GetInt32(1);
 196
 0197                        if (!userIdBlacklist.Contains(internalUserId))
 198                        {
 0199                            _logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId}
 0200                            userIdBlacklist.Add(internalUserId);
 201                        }
 202
 0203                        continue;
 204                    }
 205
 0206                    if (!legacyBaseItemWithUserKeys.TryGetValue(userData.CustomDataKey!, out var refItem))
 207                    {
 0208                        _logger.LogError("Was not able to migrate user data with key {0} because it does not reference a
 0209                        continue;
 210                    }
 211
 0212                    userData.ItemId = refItem.Id;
 0213                    operation.JellyfinDbContext.UserData.Add(userData);
 214                }
 215            }
 216
 0217            legacyBaseItemWithUserKeys.Clear();
 218
 0219            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries
 220            {
 0221                operation.JellyfinDbContext.SaveChanges();
 0222            }
 223        }
 224
 0225        using (var operation = GetPreparedDbContext("Moving MediaStreamInfos"))
 226        {
 227            const string mediaStreamQuery =
 228            """
 229            SELECT ItemId, StreamIndex, StreamType, Codec, Language, ChannelLayout, Profile, AspectRatio, Path,
 230            IsInterlaced, BitRate, Channels, SampleRate, IsDefault, IsForced, IsExternal, Height, Width,
 231            AverageFrameRate, RealFrameRate, Level, PixelFormat, BitDepth, IsAnamorphic, RefFrames, CodecTag,
 232            Comment, NalLengthSize, IsAvc, Title, TimeBase, CodecTimeBase, ColorPrimaries, ColorSpace, ColorTransfer,
 233            DvVersionMajor, DvVersionMinor, DvProfile, DvLevel, RpuPresentFlag, ElPresentFlag, BlPresentFlag, DvBlSignal
 234            FROM MediaStreams
 235            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId)
 236            """;
 237
 0238            using (new TrackedMigrationStep("Loading MediaStreamInfos", _logger))
 239            {
 0240                foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
 241                {
 0242                    operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto));
 243                }
 244            }
 245
 0246            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStr
 247            {
 0248                operation.JellyfinDbContext.SaveChanges();
 0249            }
 250        }
 251
 0252        using (var operation = GetPreparedDbContext("Moving AttachmentStreamInfos"))
 253        {
 254            const string mediaAttachmentQuery =
 255            """
 256            SELECT ItemId, AttachmentIndex, Codec, CodecTag, Comment, filename, MIMEType
 257            FROM mediaattachments
 258            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
 259            """;
 260
 0261            using (new TrackedMigrationStep("Loading AttachmentStreamInfos", _logger))
 262            {
 0263                foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
 264                {
 0265                    operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto));
 266                }
 267            }
 268
 0269            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} Att
 270            {
 0271                operation.JellyfinDbContext.SaveChanges();
 0272            }
 273        }
 274
 0275        using (var operation = GetPreparedDbContext("Moving People"))
 276        {
 277            const string personsQuery =
 278            """
 279            SELECT ItemId, Name, Role, PersonType, SortOrder FROM People
 280            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId)
 281            """;
 282
 0283            var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
 284
 0285            using (new TrackedMigrationStep("Loading People", _logger))
 286            {
 0287                foreach (SqliteDataReader reader in connection.Query(personsQuery))
 288                {
 0289                    var itemId = reader.GetGuid(0);
 0290                    if (!baseItemIds.Contains(itemId))
 291                    {
 0292                        _logger.LogError("Not saving person {0} because it's not in use by any BaseItem", reader.GetStri
 0293                        continue;
 294                    }
 295
 0296                    var entity = GetPerson(reader);
 0297                    if (!peopleCache.TryGetValue(entity.Name, out var personCache))
 298                    {
 0299                        peopleCache[entity.Name] = personCache = (entity, []);
 300                    }
 301
 0302                    if (reader.TryGetString(2, out var role))
 303                    {
 304                    }
 305
 0306                    int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4);
 307
 0308                    personCache.Items.Add(new PeopleBaseItemMap()
 0309                    {
 0310                        Item = null!,
 0311                        ItemId = itemId,
 0312                        People = null!,
 0313                        PeopleId = personCache.Person.Id,
 0314                        ListOrder = sortOrder,
 0315                        SortOrder = sortOrder,
 0316                        Role = role
 0317                    });
 318                }
 319
 0320                baseItemIds.Clear();
 321
 0322                foreach (var item in peopleCache)
 323                {
 0324                    operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
 0325                    operation.JellyfinDbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e
 326                }
 327
 0328                peopleCache.Clear();
 0329            }
 330
 0331            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries an
 332            {
 0333                operation.JellyfinDbContext.SaveChanges();
 0334            }
 335        }
 336
 0337        using (var operation = GetPreparedDbContext("Moving Chapters"))
 338        {
 339            const string chapterQuery =
 340            """
 341            SELECT ItemId,StartPositionTicks,Name,ImagePath,ImageDateModified,ChapterIndex from Chapters2
 342            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId)
 343            """;
 344
 0345            using (new TrackedMigrationStep("Loading Chapters", _logger))
 346            {
 0347                foreach (SqliteDataReader dto in connection.Query(chapterQuery))
 348                {
 0349                    var chapter = GetChapter(dto);
 0350                    operation.JellyfinDbContext.Chapters.Add(chapter);
 351                }
 352            }
 353
 0354            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries
 355            {
 0356                operation.JellyfinDbContext.SaveChanges();
 0357            }
 358        }
 359
 0360        using (var operation = GetPreparedDbContext("Moving AncestorIds"))
 361        {
 362            const string ancestorIdsQuery =
 363            """
 364            SELECT ItemId, AncestorId, AncestorIdText FROM AncestorIds
 365            WHERE
 366            EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.ItemId)
 367            AND
 368            EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId)
 369            """;
 370
 0371            using (new TrackedMigrationStep("Loading AncestorIds", _logger))
 372            {
 0373                foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
 374                {
 0375                    var ancestorId = GetAncestorId(dto);
 0376                    operation.JellyfinDbContext.AncestorIds.Add(ancestorId);
 377                }
 378            }
 379
 0380            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId en
 381            {
 0382                operation.JellyfinDbContext.SaveChanges();
 0383            }
 384        }
 385
 0386        connection.Close();
 387
 0388        _logger.LogInformation("Migration of the Library.db done.");
 0389        _logger.LogInformation("Migrating Library db took {0}.", fullOperationTimer.Elapsed);
 390
 0391        SqliteConnection.ClearAllPools();
 392
 0393        _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
 0394        File.Move(libraryDbPath, libraryDbPath + ".old", true);
 0395    }
 396
 397    private DatabaseMigrationStep GetPreparedDbContext(string operationName)
 398    {
 0399        var dbContext = _provider.CreateDbContext();
 0400        dbContext.ChangeTracker.AutoDetectChangesEnabled = false;
 0401        dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
 0402        return new DatabaseMigrationStep(dbContext, operationName, _logger);
 403    }
 404
 405    internal static UserData? GetUserData(User[] users, SqliteDataReader dto, HashSet<int> userIdBlacklist, ILogger logg
 406    {
 0407        var internalUserId = dto.GetInt32(1);
 0408        if (userIdBlacklist.Contains(internalUserId))
 409        {
 0410            return null;
 411        }
 412
 0413        var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
 0414        if (user is null)
 415        {
 0416            userIdBlacklist.Add(internalUserId);
 417
 0418            logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId
 0419            return null;
 420        }
 421
 0422        var oldKey = dto.GetString(0);
 423
 0424        return new UserData()
 0425        {
 0426            ItemId = Guid.NewGuid(),
 0427            CustomDataKey = oldKey,
 0428            UserId = user.Id,
 0429            Rating = dto.IsDBNull(2) ? null : dto.GetDouble(2),
 0430            Played = dto.GetBoolean(3),
 0431            PlayCount = dto.GetInt32(4),
 0432            IsFavorite = dto.GetBoolean(5),
 0433            PlaybackPositionTicks = dto.GetInt64(6),
 0434            LastPlayedDate = dto.IsDBNull(7) ? null : dto.GetDateTime(7),
 0435            AudioStreamIndex = dto.IsDBNull(8) ? null : dto.GetInt32(8),
 0436            SubtitleStreamIndex = dto.IsDBNull(9) ? null : dto.GetInt32(9),
 0437            Likes = null,
 0438            User = null!,
 0439            Item = null!
 0440        };
 441    }
 442
 443    private AncestorId GetAncestorId(SqliteDataReader reader)
 444    {
 0445        return new AncestorId()
 0446        {
 0447            ItemId = reader.GetGuid(0),
 0448            ParentItemId = reader.GetGuid(1),
 0449            Item = null!,
 0450            ParentItem = null!
 0451        };
 452    }
 453
 454    /// <summary>
 455    /// Gets the chapter.
 456    /// </summary>
 457    /// <param name="reader">The reader.</param>
 458    /// <returns>ChapterInfo.</returns>
 459    private Chapter GetChapter(SqliteDataReader reader)
 460    {
 0461        var chapter = new Chapter
 0462        {
 0463            StartPositionTicks = reader.GetInt64(1),
 0464            ChapterIndex = reader.GetInt32(5),
 0465            Item = null!,
 0466            ItemId = reader.GetGuid(0),
 0467        };
 468
 0469        if (reader.TryGetString(2, out var chapterName))
 470        {
 0471            chapter.Name = chapterName;
 472        }
 473
 0474        if (reader.TryGetString(3, out var imagePath))
 475        {
 0476            chapter.ImagePath = imagePath;
 477        }
 478
 0479        if (reader.TryReadDateTime(4, out var imageDateModified))
 480        {
 0481            chapter.ImageDateModified = imageDateModified;
 482        }
 483
 0484        return chapter;
 485    }
 486
 487    private ItemValue GetItemValue(SqliteDataReader reader)
 488    {
 0489        return new ItemValue
 0490        {
 0491            ItemValueId = Guid.NewGuid(),
 0492            Type = (ItemValueType)reader.GetInt32(1),
 0493            Value = reader.GetString(2),
 0494            CleanValue = reader.GetString(3),
 0495        };
 496    }
 497
 498    private People GetPerson(SqliteDataReader reader)
 499    {
 0500        var item = new People
 0501        {
 0502            Id = Guid.NewGuid(),
 0503            Name = reader.GetString(1),
 0504        };
 505
 0506        if (reader.TryGetString(3, out var type))
 507        {
 0508            item.PersonType = type;
 509        }
 510
 0511        return item;
 512    }
 513
 514    /// <summary>
 515    /// Gets the media stream.
 516    /// </summary>
 517    /// <param name="reader">The reader.</param>
 518    /// <returns>MediaStream.</returns>
 519    private MediaStreamInfo GetMediaStream(SqliteDataReader reader)
 520    {
 0521        var item = new MediaStreamInfo
 0522        {
 0523            StreamIndex = reader.GetInt32(1),
 0524            StreamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2)),
 0525            Item = null!,
 0526            ItemId = reader.GetGuid(0),
 0527            AspectRatio = null!,
 0528            ChannelLayout = null!,
 0529            Codec = null!,
 0530            IsInterlaced = false,
 0531            Language = null!,
 0532            Path = null!,
 0533            Profile = null!,
 0534        };
 535
 0536        if (reader.TryGetString(3, out var codec))
 537        {
 0538            item.Codec = codec;
 539        }
 540
 0541        if (reader.TryGetString(4, out var language))
 542        {
 0543            item.Language = language;
 544        }
 545
 0546        if (reader.TryGetString(5, out var channelLayout))
 547        {
 0548            item.ChannelLayout = channelLayout;
 549        }
 550
 0551        if (reader.TryGetString(6, out var profile))
 552        {
 0553            item.Profile = profile;
 554        }
 555
 0556        if (reader.TryGetString(7, out var aspectRatio))
 557        {
 0558            item.AspectRatio = aspectRatio;
 559        }
 560
 0561        if (reader.TryGetString(8, out var path))
 562        {
 0563            item.Path = path;
 564        }
 565
 0566        item.IsInterlaced = reader.GetBoolean(9);
 567
 0568        if (reader.TryGetInt32(10, out var bitrate))
 569        {
 0570            item.BitRate = bitrate;
 571        }
 572
 0573        if (reader.TryGetInt32(11, out var channels))
 574        {
 0575            item.Channels = channels;
 576        }
 577
 0578        if (reader.TryGetInt32(12, out var sampleRate))
 579        {
 0580            item.SampleRate = sampleRate;
 581        }
 582
 0583        item.IsDefault = reader.GetBoolean(13);
 0584        item.IsForced = reader.GetBoolean(14);
 0585        item.IsExternal = reader.GetBoolean(15);
 586
 0587        if (reader.TryGetInt32(16, out var width))
 588        {
 0589            item.Width = width;
 590        }
 591
 0592        if (reader.TryGetInt32(17, out var height))
 593        {
 0594            item.Height = height;
 595        }
 596
 0597        if (reader.TryGetSingle(18, out var averageFrameRate))
 598        {
 0599            item.AverageFrameRate = averageFrameRate;
 600        }
 601
 0602        if (reader.TryGetSingle(19, out var realFrameRate))
 603        {
 0604            item.RealFrameRate = realFrameRate;
 605        }
 606
 0607        if (reader.TryGetSingle(20, out var level))
 608        {
 0609            item.Level = level;
 610        }
 611
 0612        if (reader.TryGetString(21, out var pixelFormat))
 613        {
 0614            item.PixelFormat = pixelFormat;
 615        }
 616
 0617        if (reader.TryGetInt32(22, out var bitDepth))
 618        {
 0619            item.BitDepth = bitDepth;
 620        }
 621
 0622        if (reader.TryGetBoolean(23, out var isAnamorphic))
 623        {
 0624            item.IsAnamorphic = isAnamorphic;
 625        }
 626
 0627        if (reader.TryGetInt32(24, out var refFrames))
 628        {
 0629            item.RefFrames = refFrames;
 630        }
 631
 0632        if (reader.TryGetString(25, out var codecTag))
 633        {
 0634            item.CodecTag = codecTag;
 635        }
 636
 0637        if (reader.TryGetString(26, out var comment))
 638        {
 0639            item.Comment = comment;
 640        }
 641
 0642        if (reader.TryGetString(27, out var nalLengthSize))
 643        {
 0644            item.NalLengthSize = nalLengthSize;
 645        }
 646
 0647        if (reader.TryGetBoolean(28, out var isAVC))
 648        {
 0649            item.IsAvc = isAVC;
 650        }
 651
 0652        if (reader.TryGetString(29, out var title))
 653        {
 0654            item.Title = title;
 655        }
 656
 0657        if (reader.TryGetString(30, out var timeBase))
 658        {
 0659            item.TimeBase = timeBase;
 660        }
 661
 0662        if (reader.TryGetString(31, out var codecTimeBase))
 663        {
 0664            item.CodecTimeBase = codecTimeBase;
 665        }
 666
 0667        if (reader.TryGetString(32, out var colorPrimaries))
 668        {
 0669            item.ColorPrimaries = colorPrimaries;
 670        }
 671
 0672        if (reader.TryGetString(33, out var colorSpace))
 673        {
 0674            item.ColorSpace = colorSpace;
 675        }
 676
 0677        if (reader.TryGetString(34, out var colorTransfer))
 678        {
 0679            item.ColorTransfer = colorTransfer;
 680        }
 681
 0682        if (reader.TryGetInt32(35, out var dvVersionMajor))
 683        {
 0684            item.DvVersionMajor = dvVersionMajor;
 685        }
 686
 0687        if (reader.TryGetInt32(36, out var dvVersionMinor))
 688        {
 0689            item.DvVersionMinor = dvVersionMinor;
 690        }
 691
 0692        if (reader.TryGetInt32(37, out var dvProfile))
 693        {
 0694            item.DvProfile = dvProfile;
 695        }
 696
 0697        if (reader.TryGetInt32(38, out var dvLevel))
 698        {
 0699            item.DvLevel = dvLevel;
 700        }
 701
 0702        if (reader.TryGetInt32(39, out var rpuPresentFlag))
 703        {
 0704            item.RpuPresentFlag = rpuPresentFlag;
 705        }
 706
 0707        if (reader.TryGetInt32(40, out var elPresentFlag))
 708        {
 0709            item.ElPresentFlag = elPresentFlag;
 710        }
 711
 0712        if (reader.TryGetInt32(41, out var blPresentFlag))
 713        {
 0714            item.BlPresentFlag = blPresentFlag;
 715        }
 716
 0717        if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId))
 718        {
 0719            item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId;
 720        }
 721
 0722        item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result;
 723
 724        // if (reader.TryGetInt32(44, out var rotation))
 725        // {
 726        //     item.Rotation = rotation;
 727        // }
 728
 0729        return item;
 730    }
 731
 732    /// <summary>
 733    /// Gets the attachment.
 734    /// </summary>
 735    /// <param name="reader">The reader.</param>
 736    /// <returns>MediaAttachment.</returns>
 737    private AttachmentStreamInfo GetMediaAttachment(SqliteDataReader reader)
 738    {
 0739        var item = new AttachmentStreamInfo
 0740        {
 0741            Index = reader.GetInt32(1),
 0742            Item = null!,
 0743            ItemId = reader.GetGuid(0),
 0744        };
 745
 0746        if (reader.TryGetString(2, out var codec))
 747        {
 0748            item.Codec = codec;
 749        }
 750
 0751        if (reader.TryGetString(3, out var codecTag))
 752        {
 0753            item.CodecTag = codecTag;
 754        }
 755
 0756        if (reader.TryGetString(4, out var comment))
 757        {
 0758            item.Comment = comment;
 759        }
 760
 0761        if (reader.TryGetString(5, out var fileName))
 762        {
 0763            item.Filename = fileName;
 764        }
 765
 0766        if (reader.TryGetString(6, out var mimeType))
 767        {
 0768            item.MimeType = mimeType;
 769        }
 770
 0771        return item;
 772    }
 773
 774    private (BaseItemEntity BaseItem, string[] LegacyUserDataKey) GetItem(SqliteDataReader reader)
 775    {
 0776        var entity = new BaseItemEntity()
 0777        {
 0778            Id = reader.GetGuid(0),
 0779            Type = reader.GetString(1),
 0780        };
 781
 0782        var index = 2;
 783
 0784        if (reader.TryGetString(index++, out var data))
 785        {
 0786            entity.Data = data;
 787        }
 788
 0789        if (reader.TryReadDateTime(index++, out var startDate))
 790        {
 0791            entity.StartDate = startDate;
 792        }
 793
 0794        if (reader.TryReadDateTime(index++, out var endDate))
 795        {
 0796            entity.EndDate = endDate;
 797        }
 798
 0799        if (reader.TryGetGuid(index++, out var guid))
 800        {
 0801            entity.ChannelId = guid;
 802        }
 803
 0804        if (reader.TryGetBoolean(index++, out var isMovie))
 805        {
 0806            entity.IsMovie = isMovie;
 807        }
 808
 0809        if (reader.TryGetBoolean(index++, out var isSeries))
 810        {
 0811            entity.IsSeries = isSeries;
 812        }
 813
 0814        if (reader.TryGetString(index++, out var episodeTitle))
 815        {
 0816            entity.EpisodeTitle = episodeTitle;
 817        }
 818
 0819        if (reader.TryGetBoolean(index++, out var isRepeat))
 820        {
 0821            entity.IsRepeat = isRepeat;
 822        }
 823
 0824        if (reader.TryGetSingle(index++, out var communityRating))
 825        {
 0826            entity.CommunityRating = communityRating;
 827        }
 828
 0829        if (reader.TryGetString(index++, out var customRating))
 830        {
 0831            entity.CustomRating = customRating;
 832        }
 833
 0834        if (reader.TryGetInt32(index++, out var indexNumber))
 835        {
 0836            entity.IndexNumber = indexNumber;
 837        }
 838
 0839        if (reader.TryGetBoolean(index++, out var isLocked))
 840        {
 0841            entity.IsLocked = isLocked;
 842        }
 843
 0844        if (reader.TryGetString(index++, out var preferredMetadataLanguage))
 845        {
 0846            entity.PreferredMetadataLanguage = preferredMetadataLanguage;
 847        }
 848
 0849        if (reader.TryGetString(index++, out var preferredMetadataCountryCode))
 850        {
 0851            entity.PreferredMetadataCountryCode = preferredMetadataCountryCode;
 852        }
 853
 0854        if (reader.TryGetInt32(index++, out var width))
 855        {
 0856            entity.Width = width;
 857        }
 858
 0859        if (reader.TryGetInt32(index++, out var height))
 860        {
 0861            entity.Height = height;
 862        }
 863
 0864        if (reader.TryReadDateTime(index++, out var dateLastRefreshed))
 865        {
 0866            entity.DateLastRefreshed = dateLastRefreshed;
 867        }
 868
 0869        if (reader.TryGetString(index++, out var name))
 870        {
 0871            entity.Name = name;
 872        }
 873
 0874        if (reader.TryGetString(index++, out var restorePath))
 875        {
 0876            entity.Path = restorePath;
 877        }
 878
 0879        if (reader.TryReadDateTime(index++, out var premiereDate))
 880        {
 0881            entity.PremiereDate = premiereDate;
 882        }
 883
 0884        if (reader.TryGetString(index++, out var overview))
 885        {
 0886            entity.Overview = overview;
 887        }
 888
 0889        if (reader.TryGetInt32(index++, out var parentIndexNumber))
 890        {
 0891            entity.ParentIndexNumber = parentIndexNumber;
 892        }
 893
 0894        if (reader.TryGetInt32(index++, out var productionYear))
 895        {
 0896            entity.ProductionYear = productionYear;
 897        }
 898
 0899        if (reader.TryGetString(index++, out var officialRating))
 900        {
 0901            entity.OfficialRating = officialRating;
 902        }
 903
 0904        if (reader.TryGetString(index++, out var forcedSortName))
 905        {
 0906            entity.ForcedSortName = forcedSortName;
 907        }
 908
 0909        if (reader.TryGetInt64(index++, out var runTimeTicks))
 910        {
 0911            entity.RunTimeTicks = runTimeTicks;
 912        }
 913
 0914        if (reader.TryGetInt64(index++, out var size))
 915        {
 0916            entity.Size = size;
 917        }
 918
 0919        if (reader.TryReadDateTime(index++, out var dateCreated))
 920        {
 0921            entity.DateCreated = dateCreated;
 922        }
 923
 0924        if (reader.TryReadDateTime(index++, out var dateModified))
 925        {
 0926            entity.DateModified = dateModified;
 927        }
 928
 0929        if (reader.TryGetString(index++, out var genres))
 930        {
 0931            entity.Genres = genres;
 932        }
 933
 0934        if (reader.TryGetGuid(index++, out var parentId))
 935        {
 0936            entity.ParentId = parentId;
 937        }
 938
 0939        if (reader.TryGetGuid(index++, out var topParentId))
 940        {
 0941            entity.TopParentId = topParentId;
 942        }
 943
 0944        if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var 
 945        {
 0946            entity.Audio = audioType;
 947        }
 948
 0949        if (reader.TryGetString(index++, out var serviceName))
 950        {
 0951            entity.ExternalServiceId = serviceName;
 952        }
 953
 0954        if (reader.TryGetBoolean(index++, out var isInMixedFolder))
 955        {
 0956            entity.IsInMixedFolder = isInMixedFolder;
 957        }
 958
 0959        if (reader.TryReadDateTime(index++, out var dateLastSaved))
 960        {
 0961            entity.DateLastSaved = dateLastSaved;
 962        }
 963
 0964        if (reader.TryGetString(index++, out var lockedFields))
 965        {
 0966            entity.LockedFields = lockedFields.Split('|').Select(Enum.Parse<MetadataField>)
 0967                .Select(e => new BaseItemMetadataField()
 0968                {
 0969                    Id = (int)e,
 0970                    Item = entity,
 0971                    ItemId = entity.Id
 0972                })
 0973                .ToArray();
 974        }
 975
 0976        if (reader.TryGetString(index++, out var studios))
 977        {
 0978            entity.Studios = studios;
 979        }
 980
 0981        if (reader.TryGetString(index++, out var tags))
 982        {
 0983            entity.Tags = tags;
 984        }
 985
 0986        if (reader.TryGetString(index++, out var trailerTypes))
 987        {
 0988            entity.TrailerTypes = trailerTypes.Split('|').Select(Enum.Parse<TrailerType>)
 0989                .Select(e => new BaseItemTrailerType()
 0990                {
 0991                    Id = (int)e,
 0992                    Item = entity,
 0993                    ItemId = entity.Id
 0994                })
 0995                .ToArray();
 996        }
 997
 0998        if (reader.TryGetString(index++, out var originalTitle))
 999        {
 01000            entity.OriginalTitle = originalTitle;
 1001        }
 1002
 01003        if (reader.TryGetString(index++, out var primaryVersionId))
 1004        {
 01005            entity.PrimaryVersionId = primaryVersionId;
 1006        }
 1007
 01008        if (reader.TryReadDateTime(index++, out var dateLastMediaAdded))
 1009        {
 01010            entity.DateLastMediaAdded = dateLastMediaAdded;
 1011        }
 1012
 01013        if (reader.TryGetString(index++, out var album))
 1014        {
 01015            entity.Album = album;
 1016        }
 1017
 01018        if (reader.TryGetSingle(index++, out var lUFS))
 1019        {
 01020            entity.LUFS = lUFS;
 1021        }
 1022
 01023        if (reader.TryGetSingle(index++, out var normalizationGain))
 1024        {
 01025            entity.NormalizationGain = normalizationGain;
 1026        }
 1027
 01028        if (reader.TryGetSingle(index++, out var criticRating))
 1029        {
 01030            entity.CriticRating = criticRating;
 1031        }
 1032
 01033        if (reader.TryGetBoolean(index++, out var isVirtualItem))
 1034        {
 01035            entity.IsVirtualItem = isVirtualItem;
 1036        }
 1037
 01038        if (reader.TryGetString(index++, out var seriesName))
 1039        {
 01040            entity.SeriesName = seriesName;
 1041        }
 1042
 01043        var userDataKeys = new List<string>();
 01044        if (reader.TryGetString(index++, out var directUserDataKey))
 1045        {
 01046            userDataKeys.Add(directUserDataKey);
 1047        }
 1048
 01049        if (reader.TryGetString(index++, out var seasonName))
 1050        {
 01051            entity.SeasonName = seasonName;
 1052        }
 1053
 01054        if (reader.TryGetGuid(index++, out var seasonId))
 1055        {
 01056            entity.SeasonId = seasonId;
 1057        }
 1058
 01059        if (reader.TryGetGuid(index++, out var seriesId))
 1060        {
 01061            entity.SeriesId = seriesId;
 1062        }
 1063
 01064        if (reader.TryGetString(index++, out var presentationUniqueKey))
 1065        {
 01066            entity.PresentationUniqueKey = presentationUniqueKey;
 1067        }
 1068
 01069        if (reader.TryGetInt32(index++, out var parentalRating))
 1070        {
 01071            entity.InheritedParentalRatingValue = parentalRating;
 1072        }
 1073
 01074        if (reader.TryGetString(index++, out var externalSeriesId))
 1075        {
 01076            entity.ExternalSeriesId = externalSeriesId;
 1077        }
 1078
 01079        if (reader.TryGetString(index++, out var tagLine))
 1080        {
 01081            entity.Tagline = tagLine;
 1082        }
 1083
 01084        if (reader.TryGetString(index++, out var providerIds))
 1085        {
 01086            entity.Provider = providerIds.Split('|').Select(e => e.Split("="))
 01087            .Select(e => new BaseItemProvider()
 01088            {
 01089                Item = null!,
 01090                ProviderId = e[0],
 01091                ProviderValue = e[1]
 01092            }).ToArray();
 1093        }
 1094
 01095        if (reader.TryGetString(index++, out var imageInfos))
 1096        {
 01097            entity.Images = DeserializeImages(imageInfos).Select(f => Map(entity.Id, f)).ToArray();
 1098        }
 1099
 01100        if (reader.TryGetString(index++, out var productionLocations))
 1101        {
 01102            entity.ProductionLocations = productionLocations;
 1103        }
 1104
 01105        if (reader.TryGetString(index++, out var extraIds))
 1106        {
 01107            entity.ExtraIds = extraIds;
 1108        }
 1109
 01110        if (reader.TryGetInt32(index++, out var totalBitrate))
 1111        {
 01112            entity.TotalBitrate = totalBitrate;
 1113        }
 1114
 01115        if (reader.TryGetString(index++, out var extraTypeString) && Enum.TryParse<BaseItemExtraType>(extraTypeString, o
 1116        {
 01117            entity.ExtraType = extraType;
 1118        }
 1119
 01120        if (reader.TryGetString(index++, out var artists))
 1121        {
 01122            entity.Artists = artists;
 1123        }
 1124
 01125        if (reader.TryGetString(index++, out var albumArtists))
 1126        {
 01127            entity.AlbumArtists = albumArtists;
 1128        }
 1129
 01130        if (reader.TryGetString(index++, out var externalId))
 1131        {
 01132            entity.ExternalId = externalId;
 1133        }
 1134
 01135        if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
 1136        {
 01137            entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
 1138        }
 1139
 01140        if (reader.TryGetString(index++, out var showId))
 1141        {
 01142            entity.ShowId = showId;
 1143        }
 1144
 01145        if (reader.TryGetString(index++, out var ownerId))
 1146        {
 01147            entity.OwnerId = ownerId;
 1148        }
 1149
 01150        if (reader.TryGetString(index++, out var mediaType))
 1151        {
 01152            entity.MediaType = mediaType;
 1153        }
 1154
 01155        if (reader.TryGetString(index++, out var sortName))
 1156        {
 01157            entity.SortName = sortName;
 1158        }
 1159
 01160        if (reader.TryGetString(index++, out var cleanName))
 1161        {
 01162            entity.CleanName = cleanName;
 1163        }
 1164
 01165        if (reader.TryGetString(index++, out var unratedType))
 1166        {
 01167            entity.UnratedType = unratedType;
 1168        }
 1169
 01170        var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
 01171        var dataKeys = baseItem.GetUserDataKeys();
 01172        userDataKeys.AddRange(dataKeys);
 1173
 01174        return (entity, userDataKeys.ToArray());
 1175    }
 1176
 1177    private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e)
 1178    {
 01179        return new BaseItemImageInfo()
 01180        {
 01181            ItemId = baseItemId,
 01182            Id = Guid.NewGuid(),
 01183            Path = e.Path,
 01184            Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
 01185            DateModified = e.DateModified,
 01186            Height = e.Height,
 01187            Width = e.Width,
 01188            ImageType = (ImageInfoImageType)e.Type,
 01189            Item = null!
 01190        };
 1191    }
 1192
 1193    internal ItemImageInfo[] DeserializeImages(string value)
 1194    {
 01195        if (string.IsNullOrWhiteSpace(value))
 1196        {
 01197            return Array.Empty<ItemImageInfo>();
 1198        }
 1199
 1200        // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the data
 01201        var valueSpan = value.AsSpan();
 01202        var count = valueSpan.Count('|') + 1;
 1203
 01204        var position = 0;
 01205        var result = new ItemImageInfo[count];
 01206        foreach (var part in valueSpan.Split('|'))
 1207        {
 01208            var image = ItemImageInfoFromValueString(part);
 1209
 01210            if (image is not null)
 1211            {
 01212                result[position++] = image;
 1213            }
 1214        }
 1215
 01216        if (position == count)
 1217        {
 01218            return result;
 1219        }
 1220
 01221        if (position == 0)
 1222        {
 01223            return Array.Empty<ItemImageInfo>();
 1224        }
 1225
 1226        // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array.
 01227        return result[..position];
 1228    }
 1229
 1230    internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan<char> value)
 1231    {
 1232        const char Delimiter = '*';
 1233
 01234        var nextSegment = value.IndexOf(Delimiter);
 01235        if (nextSegment == -1)
 1236        {
 01237            return null;
 1238        }
 1239
 01240        ReadOnlySpan<char> path = value[..nextSegment];
 01241        value = value[(nextSegment + 1)..];
 01242        nextSegment = value.IndexOf(Delimiter);
 01243        if (nextSegment == -1)
 1244        {
 01245            return null;
 1246        }
 1247
 01248        ReadOnlySpan<char> dateModified = value[..nextSegment];
 01249        value = value[(nextSegment + 1)..];
 01250        nextSegment = value.IndexOf(Delimiter);
 01251        if (nextSegment == -1)
 1252        {
 01253            nextSegment = value.Length;
 1254        }
 1255
 01256        ReadOnlySpan<char> imageType = value[..nextSegment];
 1257
 01258        var image = new ItemImageInfo
 01259        {
 01260            Path = path.ToString()
 01261        };
 1262
 01263        if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks)
 01264            && ticks >= DateTime.MinValue.Ticks
 01265            && ticks <= DateTime.MaxValue.Ticks)
 1266        {
 01267            image.DateModified = new DateTime(ticks, DateTimeKind.Utc);
 1268        }
 1269        else
 1270        {
 01271            return null;
 1272        }
 1273
 01274        if (Enum.TryParse(imageType, true, out ImageType type))
 1275        {
 01276            image.Type = type;
 1277        }
 1278        else
 1279        {
 01280            return null;
 1281        }
 1282
 1283        // Optional parameters: width*height*blurhash
 01284        if (nextSegment + 1 < value.Length - 1)
 1285        {
 01286            value = value[(nextSegment + 1)..];
 01287            nextSegment = value.IndexOf(Delimiter);
 01288            if (nextSegment == -1 || nextSegment == value.Length)
 1289            {
 01290                return image;
 1291            }
 1292
 01293            ReadOnlySpan<char> widthSpan = value[..nextSegment];
 1294
 01295            value = value[(nextSegment + 1)..];
 01296            nextSegment = value.IndexOf(Delimiter);
 01297            if (nextSegment == -1)
 1298            {
 01299                nextSegment = value.Length;
 1300            }
 1301
 01302            ReadOnlySpan<char> heightSpan = value[..nextSegment];
 1303
 01304            if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width)
 01305                && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height))
 1306            {
 01307                image.Width = width;
 01308                image.Height = height;
 1309            }
 1310
 01311            if (nextSegment < value.Length - 1)
 1312            {
 01313                value = value[(nextSegment + 1)..];
 01314                var length = value.Length;
 1315
 01316                Span<char> blurHashSpan = stackalloc char[length];
 01317                for (int i = 0; i < length; i++)
 1318                {
 01319                    var c = value[i];
 01320                    blurHashSpan[i] = c switch
 01321                    {
 01322                        '/' => Delimiter,
 01323                        '\\' => '|',
 01324                        _ => c
 01325                    };
 1326                }
 1327
 01328                image.BlurHash = new string(blurHashSpan);
 1329            }
 1330        }
 1331
 01332        return image;
 1333    }
 1334
 1335    private class TrackedMigrationStep : IDisposable
 1336    {
 1337        private readonly string _operationName;
 1338        private readonly ILogger _logger;
 1339        private readonly Stopwatch _operationTimer;
 1340        private bool _disposed;
 1341
 1342        public TrackedMigrationStep(string operationName, ILogger logger)
 1343        {
 01344            _operationName = operationName;
 01345            _logger = logger;
 01346            _operationTimer = Stopwatch.StartNew();
 01347            logger.LogInformation("Start {OperationName}", operationName);
 01348        }
 1349
 1350        public bool Disposed
 1351        {
 01352            get => _disposed;
 01353            set => _disposed = value;
 1354        }
 1355
 1356        public virtual void Dispose()
 1357        {
 01358            if (Disposed)
 1359            {
 01360                return;
 1361            }
 1362
 01363            Disposed = true;
 01364            _logger.LogInformation("{OperationName} took '{Time}'", _operationName, _operationTimer.Elapsed);
 01365        }
 1366    }
 1367
 1368    private sealed class DatabaseMigrationStep : TrackedMigrationStep
 1369    {
 01370        public DatabaseMigrationStep(JellyfinDbContext jellyfinDbContext, string operationName, ILogger logger) : base(o
 1371        {
 1372            JellyfinDbContext = jellyfinDbContext;
 01373        }
 1374
 1375        public JellyfinDbContext JellyfinDbContext { get; }
 1376
 1377        public override void Dispose()
 1378        {
 01379            if (Disposed)
 1380            {
 01381                return;
 1382            }
 1383
 01384            JellyfinDbContext.Dispose();
 01385            base.Dispose();
 01386        }
 1387    }
 1388}