< 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: 649
Coverable lines: 649
Total lines: 1502
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 374
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 AM Line coverage: 0% (0/634) Branch coverage: 0% (0/364) Total lines: 14702/15/2026 - 12:13:43 AM Line coverage: 0% (0/636) Branch coverage: 0% (0/364) Total lines: 14723/2/2026 - 12:14:15 AM Line coverage: 0% (0/642) Branch coverage: 0% (0/364) Total lines: 14823/30/2026 - 12:14:34 AM Line coverage: 0% (0/650) Branch coverage: 0% (0/372) Total lines: 15045/4/2026 - 12:15:16 AM Line coverage: 0% (0/649) Branch coverage: 0% (0/374) Total lines: 1502 1/23/2026 - 12:11:06 AM Line coverage: 0% (0/634) Branch coverage: 0% (0/364) Total lines: 14702/15/2026 - 12:13:43 AM Line coverage: 0% (0/636) Branch coverage: 0% (0/364) Total lines: 14723/2/2026 - 12:14:15 AM Line coverage: 0% (0/642) Branch coverage: 0% (0/364) Total lines: 14823/30/2026 - 12:14:34 AM Line coverage: 0% (0/650) Branch coverage: 0% (0/372) Total lines: 15045/4/2026 - 12:15:16 AM Line coverage: 0% (0/649) Branch coverage: 0% (0/374) Total lines: 1502

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
Perform()0%3422580%
GetPreparedDbContext(...)100%210%
GetUserData(...)0%156120%
ReadDateTimeFromColumn(...)0%7280%
GetAncestorId(...)100%210%
GetChapter(...)0%4260%
GetItemValue(...)100%210%
GetPerson(...)0%620%
GetMediaStream(...)0%5256720%
GetMediaAttachment(...)0%110100%
GetItem(...)0%244921560%
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
 93        // notify the other migration to just silently abort because the fix has been applied here already.
 094        ReseedFolderFlag.RerunGuardFlag = true;
 95
 096        var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
 097        connection.Open();
 98
 099        var baseItemIds = new HashSet<Guid>();
 0100        using (var operation = GetPreparedDbContext("Moving TypedBaseItem"))
 101        {
 0102            IDictionary<Guid, (BaseItemEntity BaseItem, string[] Keys)> allItemsLookup = new Dictionary<Guid, (BaseItemE
 103            const string typedBaseItemsQuery =
 104            """
 105            SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie,
 106            IsSeries, EpisodeTitle, IsRepeat, CommunityRating, CustomRating, IndexNumber, IsLocked, PreferredMetadataLan
 107            PreferredMetadataCountryCode, Width, Height, DateLastRefreshed, Name, Path, PremiereDate, Overview, ParentIn
 108            ProductionYear, OfficialRating, ForcedSortName, RunTimeTicks, Size, DateCreated, DateModified, Genres, Paren
 109            Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, Origina
 110            DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, Se
 111            PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, Product
 112            ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortN
 113            """;
 0114            using (new TrackedMigrationStep("Loading TypedBaseItems", _logger))
 115            {
 0116                foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery))
 117                {
 0118                    var baseItem = GetItem(dto);
 0119                    allItemsLookup.Add(baseItem.BaseItem.Id, baseItem);
 120                }
 121            }
 122
 123            bool DoesResolve(Guid? parentId, HashSet<(BaseItemEntity BaseItem, string[] Keys)> checkStack)
 124            {
 125                if (parentId is null)
 126                {
 127                    return true;
 128                }
 129
 130                if (!allItemsLookup.TryGetValue(parentId.Value, out var parent))
 131                {
 132                    return false; // item is detached and has no root anymore.
 133                }
 134
 135                if (!checkStack.Add(parent))
 136                {
 137                    return false; // recursive structure. Abort.
 138                }
 139
 140                return DoesResolve(parent.BaseItem.ParentId, checkStack);
 141            }
 142
 0143            using (new TrackedMigrationStep("Clean TypedBaseItems hierarchy", _logger))
 144            {
 0145                var checkStack = new HashSet<(BaseItemEntity BaseItem, string[] Keys)>();
 146
 0147                foreach (var item in allItemsLookup)
 148                {
 0149                    var cachedItem = item.Value;
 0150                    if (DoesResolve(cachedItem.BaseItem.ParentId, checkStack))
 151                    {
 0152                        checkStack.Add(cachedItem);
 0153                        operation.JellyfinDbContext.BaseItems.Add(cachedItem.BaseItem);
 0154                        baseItemIds.Add(cachedItem.BaseItem.Id);
 0155                        foreach (var dataKey in cachedItem.Keys)
 156                        {
 0157                            legacyBaseItemWithUserKeys[dataKey] = cachedItem.BaseItem;
 158                        }
 159                    }
 160
 0161                    checkStack.Clear();
 162                }
 163            }
 164
 0165            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entrie
 166            {
 0167                operation.JellyfinDbContext.SaveChanges();
 0168            }
 169
 0170            allItemsLookup.Clear();
 0171        }
 172
 0173        using (var operation = GetPreparedDbContext("Moving ItemValues"))
 174        {
 175            // do not migrate inherited types as they are now properly mapped in search and lookup.
 176            const string itemValueQuery =
 177            """
 178            SELECT ItemId, Type, Value, CleanValue FROM ItemValues
 179                        WHERE Type <> 6 AND EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = ItemValues.I
 180            """;
 181
 182            // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow.
 0183            var localItems = new Dictionary<(int Type, string Value), (Database.Implementations.Entities.ItemValue ItemV
 0184            using (new TrackedMigrationStep("Loading ItemValues", _logger))
 185            {
 0186                foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
 187                {
 0188                    var itemId = dto.GetGuid(0);
 0189                    if (!baseItemIds.Contains(itemId))
 190                    {
 191                        continue;
 192                    }
 193
 0194                    var entity = GetItemValue(dto);
 0195                    var key = ((int)entity.Type, entity.Value);
 0196                    if (!localItems.TryGetValue(key, out var existing))
 197                    {
 0198                        localItems[key] = existing = (entity, []);
 199                    }
 200
 0201                    existing.ItemIds.Add(itemId);
 202                }
 203
 0204                foreach (var item in localItems)
 205                {
 0206                    operation.JellyfinDbContext.ItemValues.Add(item.Value.ItemValue);
 0207                    operation.JellyfinDbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new Ite
 0208                    {
 0209                        Item = null!,
 0210                        ItemValue = null!,
 0211                        ItemId = f,
 0212                        ItemValueId = item.Value.ItemValue.ItemValueId
 0213                    }));
 214                }
 215            }
 216
 0217            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues ent
 218            {
 0219                operation.JellyfinDbContext.SaveChanges();
 0220            }
 221        }
 222
 0223        using (var operation = GetPreparedDbContext("Moving UserData"))
 224        {
 0225            var queryResult = connection.Query(
 0226            """
 0227            SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStrea
 0228
 0229            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
 0230            """);
 231
 0232            using (new TrackedMigrationStep("Loading UserData", _logger))
 233            {
 0234                var users = operation.JellyfinDbContext.Users.AsNoTracking().ToArray();
 0235                var userIdBlacklist = new HashSet<int>();
 236
 0237                foreach (var entity in queryResult)
 238                {
 0239                    var userData = GetUserData(users, entity, userIdBlacklist, _logger);
 0240                    if (userData is null)
 241                    {
 0242                        var userDataId = entity.GetString(0);
 0243                        var internalUserId = entity.GetInt32(1);
 244
 0245                        if (!userIdBlacklist.Contains(internalUserId))
 246                        {
 0247                            _logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId}
 0248                            userIdBlacklist.Add(internalUserId);
 249                        }
 250
 0251                        continue;
 252                    }
 253
 0254                    if (!legacyBaseItemWithUserKeys.TryGetValue(userData.CustomDataKey!, out var refItem))
 255                    {
 0256                        _logger.LogError("Was not able to migrate user data with key {0} because it does not reference a
 0257                        continue;
 258                    }
 259
 0260                    if (!baseItemIds.Contains(refItem.Id))
 261                    {
 262                        continue;
 263                    }
 264
 0265                    userData.ItemId = refItem.Id;
 0266                    operation.JellyfinDbContext.UserData.Add(userData);
 267                }
 268            }
 269
 0270            legacyBaseItemWithUserKeys.Clear();
 271
 0272            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries
 273            {
 0274                operation.JellyfinDbContext.SaveChanges();
 0275            }
 276        }
 277
 0278        using (var operation = GetPreparedDbContext("Moving MediaStreamInfos"))
 279        {
 280            const string mediaStreamQuery =
 281            """
 282            SELECT ItemId, StreamIndex, StreamType, Codec, Language, ChannelLayout, Profile, AspectRatio, Path,
 283            IsInterlaced, BitRate, Channels, SampleRate, IsDefault, IsForced, IsExternal, Height, Width,
 284            AverageFrameRate, RealFrameRate, Level, PixelFormat, BitDepth, IsAnamorphic, RefFrames, CodecTag,
 285            Comment, NalLengthSize, IsAvc, Title, TimeBase, CodecTimeBase, ColorPrimaries, ColorSpace, ColorTransfer,
 286            DvVersionMajor, DvVersionMinor, DvProfile, DvLevel, RpuPresentFlag, ElPresentFlag, BlPresentFlag, DvBlSignal
 287            FROM MediaStreams
 288            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId)
 289            """;
 290
 0291            using (new TrackedMigrationStep("Loading MediaStreamInfos", _logger))
 292            {
 0293                foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
 294                {
 0295                    var entity = GetMediaStream(dto);
 0296                    if (!baseItemIds.Contains(entity.ItemId))
 297                    {
 298                        continue;
 299                    }
 300
 0301                    operation.JellyfinDbContext.MediaStreamInfos.Add(entity);
 302                }
 303            }
 304
 0305            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStr
 306            {
 0307                operation.JellyfinDbContext.SaveChanges();
 0308            }
 309        }
 310
 0311        using (var operation = GetPreparedDbContext("Moving AttachmentStreamInfos"))
 312        {
 313            const string mediaAttachmentQuery =
 314            """
 315            SELECT ItemId, AttachmentIndex, Codec, CodecTag, Comment, filename, MIMEType
 316            FROM mediaattachments
 317            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
 318            """;
 319
 0320            using (new TrackedMigrationStep("Loading AttachmentStreamInfos", _logger))
 321            {
 0322                foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
 323                {
 0324                    var entity = GetMediaAttachment(dto);
 0325                    if (!baseItemIds.Contains(entity.ItemId))
 326                    {
 327                        continue;
 328                    }
 329
 0330                    operation.JellyfinDbContext.AttachmentStreamInfos.Add(entity);
 331                }
 332            }
 333
 0334            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} Att
 335            {
 0336                operation.JellyfinDbContext.SaveChanges();
 0337            }
 338        }
 339
 0340        using (var operation = GetPreparedDbContext("Moving People"))
 341        {
 342            const string personsQuery =
 343            """
 344            SELECT ItemId, Name, Role, PersonType, SortOrder, ListOrder FROM People
 345            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId)
 346            """;
 347
 0348            var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
 349
 0350            using (new TrackedMigrationStep("Loading People", _logger))
 351            {
 0352                foreach (SqliteDataReader reader in connection.Query(personsQuery))
 353                {
 0354                    var itemId = reader.GetGuid(0);
 0355                    if (!baseItemIds.Contains(itemId))
 356                    {
 0357                        _logger.LogError("Not saving person {0} because it's not in use by any BaseItem", reader.GetStri
 0358                        continue;
 359                    }
 360
 0361                    var entity = GetPerson(reader);
 0362                    if (!peopleCache.TryGetValue(entity.Name + "|" + entity.PersonType, out var personCache))
 363                    {
 0364                        peopleCache[entity.Name + "|" + entity.PersonType] = personCache = (entity, []);
 365                    }
 366
 0367                    if (reader.TryGetString(2, out var role))
 368                    {
 369                    }
 370
 0371                    int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4);
 0372                    int? listOrder = reader.IsDBNull(5) ? null : reader.GetInt32(5);
 373
 0374                    personCache.Items.Add(new PeopleBaseItemMap()
 0375                    {
 0376                        Item = null!,
 0377                        ItemId = itemId,
 0378                        People = null!,
 0379                        PeopleId = personCache.Person.Id,
 0380                        ListOrder = listOrder,
 0381                        SortOrder = sortOrder,
 0382                        Role = role
 0383                    });
 384                }
 385
 0386                foreach (var item in peopleCache)
 387                {
 0388                    operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
 0389                    operation.JellyfinDbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e
 390                }
 391
 0392                peopleCache.Clear();
 0393            }
 394
 0395            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries an
 396            {
 0397                operation.JellyfinDbContext.SaveChanges();
 0398            }
 399        }
 400
 0401        using (var operation = GetPreparedDbContext("Moving Chapters"))
 402        {
 403            const string chapterQuery =
 404            """
 405            SELECT ItemId,StartPositionTicks,Name,ImagePath,ImageDateModified,ChapterIndex from Chapters2
 406            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId)
 407            """;
 408
 0409            using (new TrackedMigrationStep("Loading Chapters", _logger))
 410            {
 0411                foreach (SqliteDataReader dto in connection.Query(chapterQuery))
 412                {
 0413                    var chapter = GetChapter(dto);
 0414                    if (!baseItemIds.Contains(chapter.ItemId))
 415                    {
 416                        continue;
 417                    }
 418
 0419                    operation.JellyfinDbContext.Chapters.Add(chapter);
 420                }
 421            }
 422
 0423            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries
 424            {
 0425                operation.JellyfinDbContext.SaveChanges();
 0426            }
 427        }
 428
 0429        using (var operation = GetPreparedDbContext("Moving AncestorIds"))
 430        {
 431            const string ancestorIdsQuery =
 432            """
 433            SELECT ItemId, AncestorId, AncestorIdText FROM AncestorIds
 434            WHERE
 435            EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.ItemId)
 436            AND
 437            EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId)
 438            """;
 439
 0440            using (new TrackedMigrationStep("Loading AncestorIds", _logger))
 441            {
 0442                foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
 443                {
 0444                    var ancestorId = GetAncestorId(dto);
 0445                    if (!baseItemIds.Contains(ancestorId.ItemId) || !baseItemIds.Contains(ancestorId.ParentItemId))
 446                    {
 447                        continue;
 448                    }
 449
 0450                    operation.JellyfinDbContext.AncestorIds.Add(ancestorId);
 451                }
 452            }
 453
 0454            using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId en
 455            {
 0456                operation.JellyfinDbContext.SaveChanges();
 0457            }
 458        }
 459
 0460        connection.Close();
 461
 0462        _logger.LogInformation("Migration of the Library.db done.");
 0463        _logger.LogInformation("Migrating Library db took {0}.", fullOperationTimer.Elapsed);
 464
 0465        SqliteConnection.ClearAllPools();
 466
 0467        using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
 468        {
 0469            checkpointConnection.Open();
 0470            using var cmd = checkpointConnection.CreateCommand();
 0471            cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
 0472            cmd.ExecuteNonQuery();
 473        }
 474
 0475        SqliteConnection.ClearAllPools();
 476
 0477        _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
 0478        File.Move(libraryDbPath, libraryDbPath + ".old", true);
 0479    }
 480
 481    private DatabaseMigrationStep GetPreparedDbContext(string operationName)
 482    {
 0483        var dbContext = _provider.CreateDbContext();
 0484        dbContext.ChangeTracker.AutoDetectChangesEnabled = false;
 0485        dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
 0486        return new DatabaseMigrationStep(dbContext, operationName, _logger);
 487    }
 488
 489    internal static UserData? GetUserData(User[] users, SqliteDataReader dto, HashSet<int> userIdBlacklist, ILogger logg
 490    {
 0491        var internalUserId = dto.GetInt32(1);
 0492        if (userIdBlacklist.Contains(internalUserId))
 493        {
 0494            return null;
 495        }
 496
 0497        var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
 0498        if (user is null)
 499        {
 0500            userIdBlacklist.Add(internalUserId);
 501
 0502            logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId
 0503            return null;
 504        }
 505
 0506        var oldKey = dto.GetString(0);
 507
 0508        return new UserData()
 0509        {
 0510            ItemId = Guid.NewGuid(),
 0511            CustomDataKey = oldKey,
 0512            UserId = user.Id,
 0513            Rating = dto.IsDBNull(2) ? null : dto.GetDouble(2),
 0514            Played = dto.GetBoolean(3),
 0515            PlayCount = dto.GetInt32(4),
 0516            IsFavorite = dto.GetBoolean(5),
 0517            PlaybackPositionTicks = dto.GetInt64(6),
 0518            LastPlayedDate = dto.IsDBNull(7) ? null : ReadDateTimeFromColumn(dto, 7),
 0519            AudioStreamIndex = dto.IsDBNull(8) ? null : dto.GetInt32(8),
 0520            SubtitleStreamIndex = dto.IsDBNull(9) ? null : dto.GetInt32(9),
 0521            Likes = null,
 0522            User = null!,
 0523            Item = null!
 0524        };
 525    }
 526
 527    private static DateTime? ReadDateTimeFromColumn(SqliteDataReader reader, int index)
 528    {
 529        // Try reading as a formatted date string first (handles ISO-8601 dates).
 0530        if (reader.TryReadDateTime(index, out var dateTimeResult))
 531        {
 0532            return dateTimeResult;
 533        }
 534
 535        // Some databases have Unix epoch timestamps stored as integers.
 536        // SqliteDataReader.GetDateTime interprets integers as Julian dates, which crashes
 537        // for Unix epoch values. Handle them explicitly.
 0538        var rawValue = reader.GetValue(index);
 0539        if (rawValue is long unixTimestamp
 0540            && unixTimestamp > 0
 0541            && unixTimestamp <= DateTimeOffset.MaxValue.ToUnixTimeSeconds())
 542        {
 0543            return DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime;
 544        }
 545
 0546        return null;
 547    }
 548
 549    private AncestorId GetAncestorId(SqliteDataReader reader)
 550    {
 0551        return new AncestorId()
 0552        {
 0553            ItemId = reader.GetGuid(0),
 0554            ParentItemId = reader.GetGuid(1),
 0555            Item = null!,
 0556            ParentItem = null!
 0557        };
 558    }
 559
 560    /// <summary>
 561    /// Gets the chapter.
 562    /// </summary>
 563    /// <param name="reader">The reader.</param>
 564    /// <returns>ChapterInfo.</returns>
 565    private Chapter GetChapter(SqliteDataReader reader)
 566    {
 0567        var chapter = new Chapter
 0568        {
 0569            StartPositionTicks = reader.GetInt64(1),
 0570            ChapterIndex = reader.GetInt32(5),
 0571            Item = null!,
 0572            ItemId = reader.GetGuid(0),
 0573        };
 574
 0575        if (reader.TryGetString(2, out var chapterName))
 576        {
 0577            chapter.Name = chapterName;
 578        }
 579
 0580        if (reader.TryGetString(3, out var imagePath))
 581        {
 0582            chapter.ImagePath = imagePath;
 583        }
 584
 0585        if (reader.TryReadDateTime(4, out var imageDateModified))
 586        {
 0587            chapter.ImageDateModified = imageDateModified;
 588        }
 589
 0590        return chapter;
 591    }
 592
 593    private ItemValue GetItemValue(SqliteDataReader reader)
 594    {
 0595        return new ItemValue
 0596        {
 0597            ItemValueId = Guid.NewGuid(),
 0598            Type = (ItemValueType)reader.GetInt32(1),
 0599            Value = reader.GetString(2),
 0600            CleanValue = reader.GetString(3),
 0601        };
 602    }
 603
 604    private People GetPerson(SqliteDataReader reader)
 605    {
 0606        var item = new People
 0607        {
 0608            Id = Guid.NewGuid(),
 0609            Name = reader.GetString(1),
 0610        };
 611
 0612        if (reader.TryGetString(3, out var type))
 613        {
 0614            item.PersonType = type;
 615        }
 616
 0617        return item;
 618    }
 619
 620    /// <summary>
 621    /// Gets the media stream.
 622    /// </summary>
 623    /// <param name="reader">The reader.</param>
 624    /// <returns>MediaStream.</returns>
 625    private MediaStreamInfo GetMediaStream(SqliteDataReader reader)
 626    {
 0627        var item = new MediaStreamInfo
 0628        {
 0629            StreamIndex = reader.GetInt32(1),
 0630            StreamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2)),
 0631            Item = null!,
 0632            ItemId = reader.GetGuid(0),
 0633            AspectRatio = null!,
 0634            ChannelLayout = null!,
 0635            Codec = null!,
 0636            IsInterlaced = false,
 0637            Language = null!,
 0638            Path = null!,
 0639            Profile = null!,
 0640        };
 641
 0642        if (reader.TryGetString(3, out var codec))
 643        {
 0644            item.Codec = codec;
 645        }
 646
 0647        if (reader.TryGetString(4, out var language))
 648        {
 0649            item.Language = language;
 650        }
 651
 0652        if (reader.TryGetString(5, out var channelLayout))
 653        {
 0654            item.ChannelLayout = channelLayout;
 655        }
 656
 0657        if (reader.TryGetString(6, out var profile))
 658        {
 0659            item.Profile = profile;
 660        }
 661
 0662        if (reader.TryGetString(7, out var aspectRatio))
 663        {
 0664            item.AspectRatio = aspectRatio;
 665        }
 666
 0667        if (reader.TryGetString(8, out var path))
 668        {
 0669            item.Path = path;
 670        }
 671
 0672        item.IsInterlaced = reader.GetBoolean(9);
 673
 0674        if (reader.TryGetInt32(10, out var bitrate))
 675        {
 0676            item.BitRate = bitrate;
 677        }
 678
 0679        if (reader.TryGetInt32(11, out var channels))
 680        {
 0681            item.Channels = channels;
 682        }
 683
 0684        if (reader.TryGetInt32(12, out var sampleRate))
 685        {
 0686            item.SampleRate = sampleRate;
 687        }
 688
 0689        item.IsDefault = reader.GetBoolean(13);
 0690        item.IsForced = reader.GetBoolean(14);
 0691        item.IsExternal = reader.GetBoolean(15);
 692
 0693        if (reader.TryGetInt32(16, out var width))
 694        {
 0695            item.Width = width;
 696        }
 697
 0698        if (reader.TryGetInt32(17, out var height))
 699        {
 0700            item.Height = height;
 701        }
 702
 0703        if (reader.TryGetSingle(18, out var averageFrameRate))
 704        {
 0705            item.AverageFrameRate = averageFrameRate;
 706        }
 707
 0708        if (reader.TryGetSingle(19, out var realFrameRate))
 709        {
 0710            item.RealFrameRate = realFrameRate;
 711        }
 712
 0713        if (reader.TryGetSingle(20, out var level))
 714        {
 0715            item.Level = level;
 716        }
 717
 0718        if (reader.TryGetString(21, out var pixelFormat))
 719        {
 0720            item.PixelFormat = pixelFormat;
 721        }
 722
 0723        if (reader.TryGetInt32(22, out var bitDepth))
 724        {
 0725            item.BitDepth = bitDepth;
 726        }
 727
 0728        if (reader.TryGetBoolean(23, out var isAnamorphic))
 729        {
 0730            item.IsAnamorphic = isAnamorphic;
 731        }
 732
 0733        if (reader.TryGetInt32(24, out var refFrames))
 734        {
 0735            item.RefFrames = refFrames;
 736        }
 737
 0738        if (reader.TryGetString(25, out var codecTag))
 739        {
 0740            item.CodecTag = codecTag;
 741        }
 742
 0743        if (reader.TryGetString(26, out var comment))
 744        {
 0745            item.Comment = comment;
 746        }
 747
 0748        if (reader.TryGetString(27, out var nalLengthSize))
 749        {
 0750            item.NalLengthSize = nalLengthSize;
 751        }
 752
 0753        if (reader.TryGetBoolean(28, out var isAVC))
 754        {
 0755            item.IsAvc = isAVC;
 756        }
 757
 0758        if (reader.TryGetString(29, out var title))
 759        {
 0760            item.Title = title;
 761        }
 762
 0763        if (reader.TryGetString(30, out var timeBase))
 764        {
 0765            item.TimeBase = timeBase;
 766        }
 767
 0768        if (reader.TryGetString(31, out var codecTimeBase))
 769        {
 0770            item.CodecTimeBase = codecTimeBase;
 771        }
 772
 0773        if (reader.TryGetString(32, out var colorPrimaries))
 774        {
 0775            item.ColorPrimaries = colorPrimaries;
 776        }
 777
 0778        if (reader.TryGetString(33, out var colorSpace))
 779        {
 0780            item.ColorSpace = colorSpace;
 781        }
 782
 0783        if (reader.TryGetString(34, out var colorTransfer))
 784        {
 0785            item.ColorTransfer = colorTransfer;
 786        }
 787
 0788        if (reader.TryGetInt32(35, out var dvVersionMajor))
 789        {
 0790            item.DvVersionMajor = dvVersionMajor;
 791        }
 792
 0793        if (reader.TryGetInt32(36, out var dvVersionMinor))
 794        {
 0795            item.DvVersionMinor = dvVersionMinor;
 796        }
 797
 0798        if (reader.TryGetInt32(37, out var dvProfile))
 799        {
 0800            item.DvProfile = dvProfile;
 801        }
 802
 0803        if (reader.TryGetInt32(38, out var dvLevel))
 804        {
 0805            item.DvLevel = dvLevel;
 806        }
 807
 0808        if (reader.TryGetInt32(39, out var rpuPresentFlag))
 809        {
 0810            item.RpuPresentFlag = rpuPresentFlag;
 811        }
 812
 0813        if (reader.TryGetInt32(40, out var elPresentFlag))
 814        {
 0815            item.ElPresentFlag = elPresentFlag;
 816        }
 817
 0818        if (reader.TryGetInt32(41, out var blPresentFlag))
 819        {
 0820            item.BlPresentFlag = blPresentFlag;
 821        }
 822
 0823        if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId))
 824        {
 0825            item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId;
 826        }
 827
 0828        item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result;
 829
 830        // if (reader.TryGetInt32(44, out var rotation))
 831        // {
 832        //     item.Rotation = rotation;
 833        // }
 834
 0835        return item;
 836    }
 837
 838    /// <summary>
 839    /// Gets the attachment.
 840    /// </summary>
 841    /// <param name="reader">The reader.</param>
 842    /// <returns>MediaAttachment.</returns>
 843    private AttachmentStreamInfo GetMediaAttachment(SqliteDataReader reader)
 844    {
 0845        var item = new AttachmentStreamInfo
 0846        {
 0847            Index = reader.GetInt32(1),
 0848            Item = null!,
 0849            ItemId = reader.GetGuid(0),
 0850        };
 851
 0852        if (reader.TryGetString(2, out var codec))
 853        {
 0854            item.Codec = codec;
 855        }
 856
 0857        if (reader.TryGetString(3, out var codecTag))
 858        {
 0859            item.CodecTag = codecTag;
 860        }
 861
 0862        if (reader.TryGetString(4, out var comment))
 863        {
 0864            item.Comment = comment;
 865        }
 866
 0867        if (reader.TryGetString(5, out var fileName))
 868        {
 0869            item.Filename = fileName;
 870        }
 871
 0872        if (reader.TryGetString(6, out var mimeType))
 873        {
 0874            item.MimeType = mimeType;
 875        }
 876
 0877        return item;
 878    }
 879
 880    private (BaseItemEntity BaseItem, string[] LegacyUserDataKey) GetItem(SqliteDataReader reader)
 881    {
 0882        var entity = new BaseItemEntity()
 0883        {
 0884            Id = reader.GetGuid(0),
 0885            Type = reader.GetString(1),
 0886        };
 887
 0888        var index = 2;
 889
 0890        if (reader.TryGetString(index++, out var data))
 891        {
 0892            entity.Data = data;
 893        }
 894
 0895        if (reader.TryReadDateTime(index++, out var startDate))
 896        {
 0897            entity.StartDate = startDate;
 898        }
 899
 0900        if (reader.TryReadDateTime(index++, out var endDate))
 901        {
 0902            entity.EndDate = endDate;
 903        }
 904
 0905        if (reader.TryGetGuid(index++, out var guid))
 906        {
 0907            entity.ChannelId = guid;
 908        }
 909
 0910        if (reader.TryGetBoolean(index++, out var isMovie))
 911        {
 0912            entity.IsMovie = isMovie;
 913        }
 914
 0915        if (reader.TryGetBoolean(index++, out var isSeries))
 916        {
 0917            entity.IsSeries = isSeries;
 918        }
 919
 0920        if (reader.TryGetString(index++, out var episodeTitle))
 921        {
 0922            entity.EpisodeTitle = episodeTitle;
 923        }
 924
 0925        if (reader.TryGetBoolean(index++, out var isRepeat))
 926        {
 0927            entity.IsRepeat = isRepeat;
 928        }
 929
 0930        if (reader.TryGetSingle(index++, out var communityRating))
 931        {
 0932            entity.CommunityRating = communityRating;
 933        }
 934
 0935        if (reader.TryGetString(index++, out var customRating))
 936        {
 0937            entity.CustomRating = customRating;
 938        }
 939
 0940        if (reader.TryGetInt32(index++, out var indexNumber))
 941        {
 0942            entity.IndexNumber = indexNumber;
 943        }
 944
 0945        if (reader.TryGetBoolean(index++, out var isLocked))
 946        {
 0947            entity.IsLocked = isLocked;
 948        }
 949
 0950        if (reader.TryGetString(index++, out var preferredMetadataLanguage))
 951        {
 0952            entity.PreferredMetadataLanguage = preferredMetadataLanguage;
 953        }
 954
 0955        if (reader.TryGetString(index++, out var preferredMetadataCountryCode))
 956        {
 0957            entity.PreferredMetadataCountryCode = preferredMetadataCountryCode;
 958        }
 959
 0960        if (reader.TryGetInt32(index++, out var width))
 961        {
 0962            entity.Width = width;
 963        }
 964
 0965        if (reader.TryGetInt32(index++, out var height))
 966        {
 0967            entity.Height = height;
 968        }
 969
 0970        if (reader.TryReadDateTime(index++, out var dateLastRefreshed))
 971        {
 0972            entity.DateLastRefreshed = dateLastRefreshed;
 973        }
 974
 0975        if (reader.TryGetString(index++, out var name))
 976        {
 0977            entity.Name = name;
 978        }
 979
 0980        if (reader.TryGetString(index++, out var restorePath))
 981        {
 0982            entity.Path = restorePath;
 983        }
 984
 0985        if (reader.TryReadDateTime(index++, out var premiereDate))
 986        {
 0987            entity.PremiereDate = premiereDate;
 988        }
 989
 0990        if (reader.TryGetString(index++, out var overview))
 991        {
 0992            entity.Overview = overview;
 993        }
 994
 0995        if (reader.TryGetInt32(index++, out var parentIndexNumber))
 996        {
 0997            entity.ParentIndexNumber = parentIndexNumber;
 998        }
 999
 01000        if (reader.TryGetInt32(index++, out var productionYear))
 1001        {
 01002            entity.ProductionYear = productionYear;
 1003        }
 1004
 01005        if (reader.TryGetString(index++, out var officialRating))
 1006        {
 01007            entity.OfficialRating = officialRating;
 1008        }
 1009
 01010        if (reader.TryGetString(index++, out var forcedSortName))
 1011        {
 01012            entity.ForcedSortName = forcedSortName;
 1013        }
 1014
 01015        if (reader.TryGetInt64(index++, out var runTimeTicks))
 1016        {
 01017            entity.RunTimeTicks = runTimeTicks;
 1018        }
 1019
 01020        if (reader.TryGetInt64(index++, out var size))
 1021        {
 01022            entity.Size = size;
 1023        }
 1024
 01025        if (reader.TryReadDateTime(index++, out var dateCreated))
 1026        {
 01027            entity.DateCreated = dateCreated;
 1028        }
 1029
 01030        if (reader.TryReadDateTime(index++, out var dateModified))
 1031        {
 01032            entity.DateModified = dateModified;
 1033        }
 1034
 01035        if (reader.TryGetString(index++, out var genres))
 1036        {
 01037            entity.Genres = genres;
 1038        }
 1039
 01040        if (reader.TryGetGuid(index++, out var parentId))
 1041        {
 01042            entity.ParentId = parentId;
 1043        }
 1044
 01045        if (reader.TryGetGuid(index++, out var topParentId))
 1046        {
 01047            entity.TopParentId = topParentId;
 1048        }
 1049
 01050        if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var 
 1051        {
 01052            entity.Audio = audioType;
 1053        }
 1054
 01055        if (reader.TryGetString(index++, out var serviceName))
 1056        {
 01057            entity.ExternalServiceId = serviceName;
 1058        }
 1059
 01060        if (reader.TryGetBoolean(index++, out var isInMixedFolder))
 1061        {
 01062            entity.IsInMixedFolder = isInMixedFolder;
 1063        }
 1064
 01065        if (reader.TryReadDateTime(index++, out var dateLastSaved))
 1066        {
 01067            entity.DateLastSaved = dateLastSaved;
 1068        }
 1069
 01070        if (reader.TryGetString(index++, out var lockedFields))
 1071        {
 01072            entity.LockedFields = lockedFields.Split('|').Select(Enum.Parse<MetadataField>)
 01073                .Select(e => new BaseItemMetadataField()
 01074                {
 01075                    Id = (int)e,
 01076                    Item = entity,
 01077                    ItemId = entity.Id
 01078                })
 01079                .ToArray();
 1080        }
 1081
 01082        if (reader.TryGetString(index++, out var studios))
 1083        {
 01084            entity.Studios = studios;
 1085        }
 1086
 01087        if (reader.TryGetString(index++, out var tags))
 1088        {
 01089            entity.Tags = tags;
 1090        }
 1091
 01092        if (reader.TryGetString(index++, out var trailerTypes))
 1093        {
 01094            entity.TrailerTypes = trailerTypes.Split('|').Select(Enum.Parse<TrailerType>)
 01095                .Select(e => new BaseItemTrailerType()
 01096                {
 01097                    Id = (int)e,
 01098                    Item = entity,
 01099                    ItemId = entity.Id
 01100                })
 01101                .ToArray();
 1102        }
 1103
 01104        if (reader.TryGetString(index++, out var originalTitle))
 1105        {
 01106            entity.OriginalTitle = originalTitle;
 1107        }
 1108
 01109        if (reader.TryGetString(index++, out var primaryVersionId) && Guid.TryParse(primaryVersionId, out var primaryVer
 1110        {
 01111            entity.PrimaryVersionId = primaryVersionGuid;
 1112        }
 1113
 01114        if (reader.TryReadDateTime(index++, out var dateLastMediaAdded))
 1115        {
 01116            entity.DateLastMediaAdded = dateLastMediaAdded;
 1117        }
 1118
 01119        if (reader.TryGetString(index++, out var album))
 1120        {
 01121            entity.Album = album;
 1122        }
 1123
 01124        if (reader.TryGetSingle(index++, out var lUFS))
 1125        {
 01126            entity.LUFS = lUFS;
 1127        }
 1128
 01129        if (reader.TryGetSingle(index++, out var normalizationGain))
 1130        {
 01131            entity.NormalizationGain = normalizationGain;
 1132        }
 1133
 01134        if (reader.TryGetSingle(index++, out var criticRating))
 1135        {
 01136            entity.CriticRating = criticRating;
 1137        }
 1138
 01139        if (reader.TryGetBoolean(index++, out var isVirtualItem))
 1140        {
 01141            entity.IsVirtualItem = isVirtualItem;
 1142        }
 1143
 01144        if (reader.TryGetString(index++, out var seriesName))
 1145        {
 01146            entity.SeriesName = seriesName;
 1147        }
 1148
 01149        var userDataKeys = new List<string>();
 01150        if (reader.TryGetString(index++, out var directUserDataKey))
 1151        {
 01152            userDataKeys.Add(directUserDataKey);
 1153        }
 1154
 01155        if (reader.TryGetString(index++, out var seasonName))
 1156        {
 01157            entity.SeasonName = seasonName;
 1158        }
 1159
 01160        if (reader.TryGetGuid(index++, out var seasonId))
 1161        {
 01162            entity.SeasonId = seasonId;
 1163        }
 1164
 01165        if (reader.TryGetGuid(index++, out var seriesId))
 1166        {
 01167            entity.SeriesId = seriesId;
 1168        }
 1169
 01170        if (reader.TryGetString(index++, out var presentationUniqueKey))
 1171        {
 01172            entity.PresentationUniqueKey = presentationUniqueKey;
 1173        }
 1174
 01175        if (reader.TryGetInt32(index++, out var parentalRating))
 1176        {
 01177            entity.InheritedParentalRatingValue = parentalRating;
 1178        }
 1179
 01180        if (reader.TryGetString(index++, out var externalSeriesId))
 1181        {
 01182            entity.ExternalSeriesId = externalSeriesId;
 1183        }
 1184
 01185        if (reader.TryGetString(index++, out var tagLine))
 1186        {
 01187            entity.Tagline = tagLine;
 1188        }
 1189
 01190        if (reader.TryGetString(index++, out var providerIds))
 1191        {
 01192            entity.Provider = providerIds.Split('|').Select(e => e.Split("=")).Where(e => e.Length >= 2)
 01193            .Select(e => new BaseItemProvider()
 01194            {
 01195                Item = null!,
 01196                ProviderId = e[0],
 01197                ProviderValue = string.Join('|', e.Skip(1))
 01198            })
 01199            .DistinctBy(e => e.ProviderId)
 01200            .ToArray();
 1201        }
 1202
 01203        if (reader.TryGetString(index++, out var imageInfos))
 1204        {
 01205            entity.Images = DeserializeImages(imageInfos).Select(f => Map(entity.Id, f)).ToArray();
 1206        }
 1207
 01208        if (reader.TryGetString(index++, out var productionLocations))
 1209        {
 01210            entity.ProductionLocations = productionLocations;
 1211        }
 1212
 1213        // Skip ExtraIds column (removed - extras are now tracked via OwnerId relationship)
 01214        index++;
 1215
 01216        if (reader.TryGetInt32(index++, out var totalBitrate))
 1217        {
 01218            entity.TotalBitrate = totalBitrate;
 1219        }
 1220
 01221        if (reader.TryGetString(index++, out var extraTypeString) && Enum.TryParse<BaseItemExtraType>(extraTypeString, o
 1222        {
 01223            entity.ExtraType = extraType;
 1224        }
 1225
 01226        if (reader.TryGetString(index++, out var artists))
 1227        {
 01228            entity.Artists = artists;
 1229        }
 1230
 01231        if (reader.TryGetString(index++, out var albumArtists))
 1232        {
 01233            entity.AlbumArtists = albumArtists;
 1234        }
 1235
 01236        if (reader.TryGetString(index++, out var externalId))
 1237        {
 01238            entity.ExternalId = externalId;
 1239        }
 1240
 01241        if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
 1242        {
 01243            entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
 1244        }
 1245
 01246        if (reader.TryGetString(index++, out var showId))
 1247        {
 01248            entity.ShowId = showId;
 1249        }
 1250
 01251        if (reader.TryGetString(index++, out var ownerId) && Guid.TryParse(ownerId, out var ownerIdGuid))
 1252        {
 01253            entity.OwnerId = ownerIdGuid;
 1254        }
 1255
 01256        if (reader.TryGetString(index++, out var mediaType))
 1257        {
 01258            entity.MediaType = mediaType;
 1259        }
 1260
 01261        if (reader.TryGetString(index++, out var sortName))
 1262        {
 01263            entity.SortName = sortName;
 1264        }
 1265
 01266        if (reader.TryGetString(index++, out var cleanName))
 1267        {
 01268            entity.CleanName = cleanName;
 1269        }
 1270
 01271        if (reader.TryGetString(index++, out var unratedType))
 1272        {
 01273            entity.UnratedType = unratedType;
 1274        }
 1275
 01276        if (reader.TryGetBoolean(index++, out var isFolder))
 1277        {
 01278            entity.IsFolder = isFolder;
 1279        }
 1280
 01281        var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
 01282        if (baseItem is not null)
 1283        {
 01284            var dataKeys = baseItem.GetUserDataKeys();
 01285            userDataKeys.AddRange(dataKeys);
 1286        }
 1287
 01288        return (entity, userDataKeys.ToArray());
 1289    }
 1290
 1291    private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e)
 1292    {
 01293        return new BaseItemImageInfo()
 01294        {
 01295            ItemId = baseItemId,
 01296            Id = Guid.NewGuid(),
 01297            Path = e.Path,
 01298            Blurhash = e.BlurHash is not null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
 01299            DateModified = e.DateModified,
 01300            Height = e.Height,
 01301            Width = e.Width,
 01302            ImageType = (ImageInfoImageType)e.Type,
 01303            Item = null!
 01304        };
 1305    }
 1306
 1307    internal ItemImageInfo[] DeserializeImages(string value)
 1308    {
 01309        if (string.IsNullOrWhiteSpace(value))
 1310        {
 01311            return Array.Empty<ItemImageInfo>();
 1312        }
 1313
 1314        // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the data
 01315        var valueSpan = value.AsSpan();
 01316        var count = valueSpan.Count('|') + 1;
 1317
 01318        var position = 0;
 01319        var result = new ItemImageInfo[count];
 01320        foreach (var part in valueSpan.Split('|'))
 1321        {
 01322            var image = ItemImageInfoFromValueString(part);
 1323
 01324            if (image is not null)
 1325            {
 01326                result[position++] = image;
 1327            }
 1328        }
 1329
 01330        if (position == count)
 1331        {
 01332            return result;
 1333        }
 1334
 01335        if (position == 0)
 1336        {
 01337            return Array.Empty<ItemImageInfo>();
 1338        }
 1339
 1340        // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array.
 01341        return result[..position];
 1342    }
 1343
 1344    internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan<char> value)
 1345    {
 1346        const char Delimiter = '*';
 1347
 01348        var nextSegment = value.IndexOf(Delimiter);
 01349        if (nextSegment == -1)
 1350        {
 01351            return null;
 1352        }
 1353
 01354        ReadOnlySpan<char> path = value[..nextSegment];
 01355        value = value[(nextSegment + 1)..];
 01356        nextSegment = value.IndexOf(Delimiter);
 01357        if (nextSegment == -1)
 1358        {
 01359            return null;
 1360        }
 1361
 01362        ReadOnlySpan<char> dateModified = value[..nextSegment];
 01363        value = value[(nextSegment + 1)..];
 01364        nextSegment = value.IndexOf(Delimiter);
 01365        if (nextSegment == -1)
 1366        {
 01367            nextSegment = value.Length;
 1368        }
 1369
 01370        ReadOnlySpan<char> imageType = value[..nextSegment];
 1371
 01372        var image = new ItemImageInfo
 01373        {
 01374            Path = path.ToString()
 01375        };
 1376
 01377        if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks)
 01378            && ticks >= DateTime.MinValue.Ticks
 01379            && ticks <= DateTime.MaxValue.Ticks)
 1380        {
 01381            image.DateModified = new DateTime(ticks, DateTimeKind.Utc);
 1382        }
 1383        else
 1384        {
 01385            return null;
 1386        }
 1387
 01388        if (Enum.TryParse(imageType, true, out ImageType type))
 1389        {
 01390            image.Type = type;
 1391        }
 1392        else
 1393        {
 01394            return null;
 1395        }
 1396
 1397        // Optional parameters: width*height*blurhash
 01398        if (nextSegment + 1 < value.Length - 1)
 1399        {
 01400            value = value[(nextSegment + 1)..];
 01401            nextSegment = value.IndexOf(Delimiter);
 01402            if (nextSegment == -1 || nextSegment == value.Length)
 1403            {
 01404                return image;
 1405            }
 1406
 01407            ReadOnlySpan<char> widthSpan = value[..nextSegment];
 1408
 01409            value = value[(nextSegment + 1)..];
 01410            nextSegment = value.IndexOf(Delimiter);
 01411            if (nextSegment == -1)
 1412            {
 01413                nextSegment = value.Length;
 1414            }
 1415
 01416            ReadOnlySpan<char> heightSpan = value[..nextSegment];
 1417
 01418            if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width)
 01419                && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height))
 1420            {
 01421                image.Width = width;
 01422                image.Height = height;
 1423            }
 1424
 01425            if (nextSegment < value.Length - 1)
 1426            {
 01427                value = value[(nextSegment + 1)..];
 01428                var length = value.Length;
 1429
 01430                Span<char> blurHashSpan = stackalloc char[length];
 01431                for (int i = 0; i < length; i++)
 1432                {
 01433                    var c = value[i];
 01434                    blurHashSpan[i] = c switch
 01435                    {
 01436                        '/' => Delimiter,
 01437                        '\\' => '|',
 01438                        _ => c
 01439                    };
 1440                }
 1441
 01442                image.BlurHash = new string(blurHashSpan);
 1443            }
 1444        }
 1445
 01446        return image;
 1447    }
 1448
 1449    private class TrackedMigrationStep : IDisposable
 1450    {
 1451        private readonly string _operationName;
 1452        private readonly ILogger _logger;
 1453        private readonly Stopwatch _operationTimer;
 1454        private bool _disposed;
 1455
 1456        public TrackedMigrationStep(string operationName, ILogger logger)
 1457        {
 01458            _operationName = operationName;
 01459            _logger = logger;
 01460            _operationTimer = Stopwatch.StartNew();
 01461            logger.LogInformation("Start {OperationName}", operationName);
 01462        }
 1463
 1464        public bool Disposed
 1465        {
 01466            get => _disposed;
 01467            set => _disposed = value;
 1468        }
 1469
 1470        public virtual void Dispose()
 1471        {
 01472            if (Disposed)
 1473            {
 01474                return;
 1475            }
 1476
 01477            Disposed = true;
 01478            _logger.LogInformation("{OperationName} took '{Time}'", _operationName, _operationTimer.Elapsed);
 01479        }
 1480    }
 1481
 1482    private sealed class DatabaseMigrationStep : TrackedMigrationStep
 1483    {
 01484        public DatabaseMigrationStep(JellyfinDbContext jellyfinDbContext, string operationName, ILogger logger) : base(o
 1485        {
 1486            JellyfinDbContext = jellyfinDbContext;
 01487        }
 1488
 1489        public JellyfinDbContext JellyfinDbContext { get; }
 1490
 1491        public override void Dispose()
 1492        {
 01493            if (Disposed)
 1494            {
 01495                return;
 1496            }
 1497
 01498            JellyfinDbContext.Dispose();
 01499            base.Dispose();
 01500        }
 1501    }
 1502}