< 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: 650
Coverable lines: 650
Total lines: 1504
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 372
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 12/27/2025 - 12:11:51 AM Line coverage: 0% (0/633) Branch coverage: 0% (0/362) Total lines: 14671/10/2026 - 12:12:36 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: 1504 12/27/2025 - 12:11:51 AM Line coverage: 0% (0/633) Branch coverage: 0% (0/362) Total lines: 14671/10/2026 - 12:12:36 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: 1504

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%238701540%
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))
 1110        {
 01111            entity.PrimaryVersionId = primaryVersionId;
 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
 01213        if (reader.TryGetString(index++, out var extraIds))
 1214        {
 01215            entity.ExtraIds = extraIds;
 1216        }
 1217
 01218        if (reader.TryGetInt32(index++, out var totalBitrate))
 1219        {
 01220            entity.TotalBitrate = totalBitrate;
 1221        }
 1222
 01223        if (reader.TryGetString(index++, out var extraTypeString) && Enum.TryParse<BaseItemExtraType>(extraTypeString, o
 1224        {
 01225            entity.ExtraType = extraType;
 1226        }
 1227
 01228        if (reader.TryGetString(index++, out var artists))
 1229        {
 01230            entity.Artists = artists;
 1231        }
 1232
 01233        if (reader.TryGetString(index++, out var albumArtists))
 1234        {
 01235            entity.AlbumArtists = albumArtists;
 1236        }
 1237
 01238        if (reader.TryGetString(index++, out var externalId))
 1239        {
 01240            entity.ExternalId = externalId;
 1241        }
 1242
 01243        if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
 1244        {
 01245            entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
 1246        }
 1247
 01248        if (reader.TryGetString(index++, out var showId))
 1249        {
 01250            entity.ShowId = showId;
 1251        }
 1252
 01253        if (reader.TryGetString(index++, out var ownerId))
 1254        {
 01255            entity.OwnerId = ownerId;
 1256        }
 1257
 01258        if (reader.TryGetString(index++, out var mediaType))
 1259        {
 01260            entity.MediaType = mediaType;
 1261        }
 1262
 01263        if (reader.TryGetString(index++, out var sortName))
 1264        {
 01265            entity.SortName = sortName;
 1266        }
 1267
 01268        if (reader.TryGetString(index++, out var cleanName))
 1269        {
 01270            entity.CleanName = cleanName;
 1271        }
 1272
 01273        if (reader.TryGetString(index++, out var unratedType))
 1274        {
 01275            entity.UnratedType = unratedType;
 1276        }
 1277
 01278        if (reader.TryGetBoolean(index++, out var isFolder))
 1279        {
 01280            entity.IsFolder = isFolder;
 1281        }
 1282
 01283        var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
 01284        if (baseItem is not null)
 1285        {
 01286            var dataKeys = baseItem.GetUserDataKeys();
 01287            userDataKeys.AddRange(dataKeys);
 1288        }
 1289
 01290        return (entity, userDataKeys.ToArray());
 1291    }
 1292
 1293    private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e)
 1294    {
 01295        return new BaseItemImageInfo()
 01296        {
 01297            ItemId = baseItemId,
 01298            Id = Guid.NewGuid(),
 01299            Path = e.Path,
 01300            Blurhash = e.BlurHash is not null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
 01301            DateModified = e.DateModified,
 01302            Height = e.Height,
 01303            Width = e.Width,
 01304            ImageType = (ImageInfoImageType)e.Type,
 01305            Item = null!
 01306        };
 1307    }
 1308
 1309    internal ItemImageInfo[] DeserializeImages(string value)
 1310    {
 01311        if (string.IsNullOrWhiteSpace(value))
 1312        {
 01313            return Array.Empty<ItemImageInfo>();
 1314        }
 1315
 1316        // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the data
 01317        var valueSpan = value.AsSpan();
 01318        var count = valueSpan.Count('|') + 1;
 1319
 01320        var position = 0;
 01321        var result = new ItemImageInfo[count];
 01322        foreach (var part in valueSpan.Split('|'))
 1323        {
 01324            var image = ItemImageInfoFromValueString(part);
 1325
 01326            if (image is not null)
 1327            {
 01328                result[position++] = image;
 1329            }
 1330        }
 1331
 01332        if (position == count)
 1333        {
 01334            return result;
 1335        }
 1336
 01337        if (position == 0)
 1338        {
 01339            return Array.Empty<ItemImageInfo>();
 1340        }
 1341
 1342        // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array.
 01343        return result[..position];
 1344    }
 1345
 1346    internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan<char> value)
 1347    {
 1348        const char Delimiter = '*';
 1349
 01350        var nextSegment = value.IndexOf(Delimiter);
 01351        if (nextSegment == -1)
 1352        {
 01353            return null;
 1354        }
 1355
 01356        ReadOnlySpan<char> path = value[..nextSegment];
 01357        value = value[(nextSegment + 1)..];
 01358        nextSegment = value.IndexOf(Delimiter);
 01359        if (nextSegment == -1)
 1360        {
 01361            return null;
 1362        }
 1363
 01364        ReadOnlySpan<char> dateModified = value[..nextSegment];
 01365        value = value[(nextSegment + 1)..];
 01366        nextSegment = value.IndexOf(Delimiter);
 01367        if (nextSegment == -1)
 1368        {
 01369            nextSegment = value.Length;
 1370        }
 1371
 01372        ReadOnlySpan<char> imageType = value[..nextSegment];
 1373
 01374        var image = new ItemImageInfo
 01375        {
 01376            Path = path.ToString()
 01377        };
 1378
 01379        if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks)
 01380            && ticks >= DateTime.MinValue.Ticks
 01381            && ticks <= DateTime.MaxValue.Ticks)
 1382        {
 01383            image.DateModified = new DateTime(ticks, DateTimeKind.Utc);
 1384        }
 1385        else
 1386        {
 01387            return null;
 1388        }
 1389
 01390        if (Enum.TryParse(imageType, true, out ImageType type))
 1391        {
 01392            image.Type = type;
 1393        }
 1394        else
 1395        {
 01396            return null;
 1397        }
 1398
 1399        // Optional parameters: width*height*blurhash
 01400        if (nextSegment + 1 < value.Length - 1)
 1401        {
 01402            value = value[(nextSegment + 1)..];
 01403            nextSegment = value.IndexOf(Delimiter);
 01404            if (nextSegment == -1 || nextSegment == value.Length)
 1405            {
 01406                return image;
 1407            }
 1408
 01409            ReadOnlySpan<char> widthSpan = value[..nextSegment];
 1410
 01411            value = value[(nextSegment + 1)..];
 01412            nextSegment = value.IndexOf(Delimiter);
 01413            if (nextSegment == -1)
 1414            {
 01415                nextSegment = value.Length;
 1416            }
 1417
 01418            ReadOnlySpan<char> heightSpan = value[..nextSegment];
 1419
 01420            if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width)
 01421                && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height))
 1422            {
 01423                image.Width = width;
 01424                image.Height = height;
 1425            }
 1426
 01427            if (nextSegment < value.Length - 1)
 1428            {
 01429                value = value[(nextSegment + 1)..];
 01430                var length = value.Length;
 1431
 01432                Span<char> blurHashSpan = stackalloc char[length];
 01433                for (int i = 0; i < length; i++)
 1434                {
 01435                    var c = value[i];
 01436                    blurHashSpan[i] = c switch
 01437                    {
 01438                        '/' => Delimiter,
 01439                        '\\' => '|',
 01440                        _ => c
 01441                    };
 1442                }
 1443
 01444                image.BlurHash = new string(blurHashSpan);
 1445            }
 1446        }
 1447
 01448        return image;
 1449    }
 1450
 1451    private class TrackedMigrationStep : IDisposable
 1452    {
 1453        private readonly string _operationName;
 1454        private readonly ILogger _logger;
 1455        private readonly Stopwatch _operationTimer;
 1456        private bool _disposed;
 1457
 1458        public TrackedMigrationStep(string operationName, ILogger logger)
 1459        {
 01460            _operationName = operationName;
 01461            _logger = logger;
 01462            _operationTimer = Stopwatch.StartNew();
 01463            logger.LogInformation("Start {OperationName}", operationName);
 01464        }
 1465
 1466        public bool Disposed
 1467        {
 01468            get => _disposed;
 01469            set => _disposed = value;
 1470        }
 1471
 1472        public virtual void Dispose()
 1473        {
 01474            if (Disposed)
 1475            {
 01476                return;
 1477            }
 1478
 01479            Disposed = true;
 01480            _logger.LogInformation("{OperationName} took '{Time}'", _operationName, _operationTimer.Elapsed);
 01481        }
 1482    }
 1483
 1484    private sealed class DatabaseMigrationStep : TrackedMigrationStep
 1485    {
 01486        public DatabaseMigrationStep(JellyfinDbContext jellyfinDbContext, string operationName, ILogger logger) : base(o
 1487        {
 1488            JellyfinDbContext = jellyfinDbContext;
 01489        }
 1490
 1491        public JellyfinDbContext JellyfinDbContext { get; }
 1492
 1493        public override void Dispose()
 1494        {
 01495            if (Disposed)
 1496            {
 01497                return;
 1498            }
 1499
 01500            JellyfinDbContext.Dispose();
 01501            base.Dispose();
 01502        }
 1503    }
 1504}