Browse Source

Expanded BaseItem aggregate types

JPVenson 9 months ago
parent
commit
eb601e944c

+ 11 - 30
Emby.Server.Implementations/Data/ItemTypeLookup.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Threading.Channels;
 using Emby.Server.Implementations.Playlists;
 using Jellyfin.Data.Enums;
+using Jellyfin.Server.Implementations;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Entities.Movies;
@@ -14,19 +15,13 @@ using MediaBrowser.Model.Querying;
 
 namespace Emby.Server.Implementations.Data;
 
-/// <summary>
-/// Provides static topic based lookups for the BaseItemKind.
-/// </summary>
+/// <inheritdoc />
 public class ItemTypeLookup : IItemTypeLookup
 {
-    /// <summary>
-    /// Gets all values of the ItemFields type.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<ItemFields> AllItemFields { get; } = Enum.GetValues<ItemFields>();
 
-    /// <summary>
-    /// Gets all BaseItemKinds that are considered Programs.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<BaseItemKind> ProgramTypes { get; } =
     [
             BaseItemKind.Program,
@@ -35,9 +30,7 @@ public class ItemTypeLookup : IItemTypeLookup
             BaseItemKind.LiveTvChannel
     ];
 
-    /// <summary>
-    /// Gets all BaseItemKinds that should be excluded from parent lookup.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<BaseItemKind> ProgramExcludeParentTypes { get; } =
     [
             BaseItemKind.Series,
@@ -47,27 +40,21 @@ public class ItemTypeLookup : IItemTypeLookup
             BaseItemKind.PhotoAlbum
     ];
 
-    /// <summary>
-    /// Gets all BaseItemKinds that are considered to be provided by services.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<BaseItemKind> ServiceTypes { get; } =
     [
             BaseItemKind.TvChannel,
             BaseItemKind.LiveTvChannel
     ];
 
-    /// <summary>
-    /// Gets all BaseItemKinds that have a StartDate.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<BaseItemKind> StartDateTypes { get; } =
     [
             BaseItemKind.Program,
             BaseItemKind.LiveTvProgram
     ];
 
-    /// <summary>
-    /// Gets all BaseItemKinds that are considered Series.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<BaseItemKind> SeriesTypes { get; } =
     [
             BaseItemKind.Book,
@@ -76,9 +63,7 @@ public class ItemTypeLookup : IItemTypeLookup
             BaseItemKind.Season
     ];
 
-    /// <summary>
-    /// Gets all BaseItemKinds that are not to be evaluated for Artists.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<BaseItemKind> ArtistExcludeParentTypes { get; } =
     [
             BaseItemKind.Series,
@@ -86,9 +71,7 @@ public class ItemTypeLookup : IItemTypeLookup
             BaseItemKind.PhotoAlbum
     ];
 
-    /// <summary>
-    /// Gets all BaseItemKinds that are considered Artists.
-    /// </summary>
+    /// <inheritdoc />
     public IReadOnlyList<BaseItemKind> ArtistsTypes { get; } =
     [
             BaseItemKind.Audio,
@@ -97,9 +80,7 @@ public class ItemTypeLookup : IItemTypeLookup
             BaseItemKind.AudioBook
     ];
 
-    /// <summary>
-    /// Gets mapping for all BaseItemKinds and their expected serialisaition target.
-    /// </summary>
+    /// <inheritdoc />
     public IDictionary<BaseItemKind, string?> BaseItemKindNames { get; } = new Dictionary<BaseItemKind, string?>()
     {
         { BaseItemKind.AggregateFolder, typeof(AggregateFolder).FullName },

+ 10 - 12
Jellyfin.Data/Entities/BaseItemEntity.cs

@@ -10,9 +10,7 @@ namespace Jellyfin.Data.Entities;
 
 public class BaseItemEntity
 {
-    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
-
-    public Guid Id { get; set; }
+    public required Guid Id { get; set; }
 
     public required string Type { get; set; }
 
@@ -78,12 +76,8 @@ public class BaseItemEntity
 
     public bool IsInMixedFolder { get; set; }
 
-    public string? LockedFields { get; set; }
-
     public string? Studios { get; set; }
 
-    public string? Audio { get; set; }
-
     public string? ExternalServiceId { get; set; }
 
     public string? Tags { get; set; }
@@ -94,8 +88,6 @@ public class BaseItemEntity
 
     public string? UnratedType { get; set; }
 
-    public string? TrailerTypes { get; set; }
-
     public float? CriticRating { get; set; }
 
     public string? CleanName { get; set; }
@@ -126,15 +118,13 @@ public class BaseItemEntity
 
     public string? Tagline { get; set; }
 
-    public string? Images { get; set; }
-
     public string? ProductionLocations { get; set; }
 
     public string? ExtraIds { get; set; }
 
     public int? TotalBitrate { get; set; }
 
-    public string? ExtraType { get; set; }
+    public BaseItemExtraType? ExtraType { get; set; }
 
     public string? Artists { get; set; }
 
@@ -154,6 +144,8 @@ public class BaseItemEntity
 
     public long? Size { get; set; }
 
+    public ProgramAudioEntity? Audio { get; set; }
+
     public Guid? ParentId { get; set; }
 
     public Guid? TopParentId { get; set; }
@@ -176,6 +168,12 @@ public class BaseItemEntity
 
     public ICollection<AncestorId>? AncestorIds { get; set; }
 
+    public ICollection<BaseItemMetadataField>? LockedFields { get; set; }
+
+    public ICollection<BaseItemTrailerType>? TrailerTypes { get; set; }
+
+    public ICollection<BaseItemImageInfo>? Images { get; set; }
+
     // those are references to __LOCAL__ ids not DB ids ... TODO: Bring the whole folder structure into the DB
     // public ICollection<BaseItemEntity>? SeriesEpisodes { get; set; }
     // public BaseItemEntity? Series { get; set; }

+ 18 - 0
Jellyfin.Data/Entities/BaseItemExtraType.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Data.Entities;
+
+#pragma warning disable CS1591
+public enum BaseItemExtraType
+{
+    Unknown = 0,
+    Clip = 1,
+    Trailer = 2,
+    BehindTheScenes = 3,
+    DeletedScene = 4,
+    Interview = 5,
+    Scene = 6,
+    Sample = 7,
+    ThemeSong = 8,
+    ThemeVideo = 9,
+    Featurette = 10,
+    Short = 11
+}

+ 57 - 0
Jellyfin.Data/Entities/BaseItemImageInfo.cs

@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Data.Entities;
+#pragma warning disable CA2227
+
+/// <summary>
+/// Enum TrailerTypes.
+/// </summary>
+public class BaseItemImageInfo
+{
+    /// <summary>
+    /// Gets or Sets.
+    /// </summary>
+    public required Guid Id { get; set; }
+
+    /// <summary>
+    /// Gets or Sets the path to the original image.
+    /// </summary>
+    public required string Path { get; set; }
+
+    /// <summary>
+    /// Gets or Sets the time the image was last modified.
+    /// </summary>
+    public DateTime DateModified { get; set; }
+
+    /// <summary>
+    /// Gets or Sets the imagetype.
+    /// </summary>
+    public ImageInfoImageType ImageType { get; set; }
+
+    /// <summary>
+    /// Gets or Sets the width of the original image.
+    /// </summary>
+    public int Width { get; set; }
+
+    /// <summary>
+    /// Gets or Sets the height of the original image.
+    /// </summary>
+    public int Height { get; set; }
+
+#pragma warning disable CA1819
+    /// <summary>
+    /// Gets or Sets the blurhash.
+    /// </summary>
+    public byte[]? Blurhash { get; set; }
+
+    /// <summary>
+    /// Gets or Sets the reference id to the BaseItem.
+    /// </summary>
+    public required Guid ItemId { get; set; }
+
+    /// <summary>
+    /// Gets or Sets the referenced Item.
+    /// </summary>
+    public required BaseItemEntity Item { get; set; }
+}

+ 26 - 0
Jellyfin.Data/Entities/BaseItemMetadataField.cs

@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Data.Entities;
+#pragma warning disable CA2227
+
+/// <summary>
+/// Enum MetadataFields.
+/// </summary>
+public class BaseItemMetadataField
+{
+    /// <summary>
+    /// Gets or Sets Numerical ID of this enumeratable.
+    /// </summary>
+    public required int Id { get; set; }
+
+    /// <summary>
+    /// Gets or Sets all referenced <see cref="BaseItemEntity"/>.
+    /// </summary>
+    public required Guid ItemId { get; set; }
+
+    /// <summary>
+    /// Gets or Sets all referenced <see cref="BaseItemEntity"/>.
+    /// </summary>
+    public required BaseItemEntity Item { get; set; }
+}

+ 25 - 0
Jellyfin.Data/Entities/BaseItemTrailerType.cs

@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Data.Entities;
+#pragma warning disable CA2227
+/// <summary>
+/// Enum TrailerTypes.
+/// </summary>
+public class BaseItemTrailerType
+{
+    /// <summary>
+    /// Gets or Sets Numerical ID of this enumeratable.
+    /// </summary>
+    public required int Id { get; set; }
+
+    /// <summary>
+    /// Gets or Sets all referenced <see cref="BaseItemEntity"/>.
+    /// </summary>
+    public required Guid ItemId { get; set; }
+
+    /// <summary>
+    /// Gets or Sets all referenced <see cref="BaseItemEntity"/>.
+    /// </summary>
+    public required BaseItemEntity Item { get; set; }
+}

+ 14 - 0
Jellyfin.Data/Entities/EnumLikeTable.cs

@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+
+namespace Jellyfin.Data.Entities;
+
+/// <summary>
+/// Defines an Entity that is modeled after an Enum.
+/// </summary>
+public abstract class EnumLikeTable
+{
+    /// <summary>
+    /// Gets or Sets Numerical ID of this enumeratable.
+    /// </summary>
+    public required int Id { get; set; }
+}

+ 76 - 0
Jellyfin.Data/Entities/ImageInfoImageType.cs

@@ -0,0 +1,76 @@
+namespace Jellyfin.Data.Entities;
+
+/// <summary>
+/// Enum ImageType.
+/// </summary>
+public enum ImageInfoImageType
+{
+    /// <summary>
+    /// The primary.
+    /// </summary>
+    Primary = 0,
+
+    /// <summary>
+    /// The art.
+    /// </summary>
+    Art = 1,
+
+    /// <summary>
+    /// The backdrop.
+    /// </summary>
+    Backdrop = 2,
+
+    /// <summary>
+    /// The banner.
+    /// </summary>
+    Banner = 3,
+
+    /// <summary>
+    /// The logo.
+    /// </summary>
+    Logo = 4,
+
+    /// <summary>
+    /// The thumb.
+    /// </summary>
+    Thumb = 5,
+
+    /// <summary>
+    /// The disc.
+    /// </summary>
+    Disc = 6,
+
+    /// <summary>
+    /// The box.
+    /// </summary>
+    Box = 7,
+
+    /// <summary>
+    /// The screenshot.
+    /// </summary>
+    /// <remarks>
+    /// This enum value is obsolete.
+    /// XmlSerializer does not serialize/deserialize objects that are marked as [Obsolete].
+    /// </remarks>
+    Screenshot = 8,
+
+    /// <summary>
+    /// The menu.
+    /// </summary>
+    Menu = 9,
+
+    /// <summary>
+    /// The chapter image.
+    /// </summary>
+    Chapter = 10,
+
+    /// <summary>
+    /// The box rear.
+    /// </summary>
+    BoxRear = 11,
+
+    /// <summary>
+    /// The user profile image.
+    /// </summary>
+    Profile = 12
+}

+ 37 - 0
Jellyfin.Data/Entities/ProgramAudioEntity.cs

@@ -0,0 +1,37 @@
+namespace Jellyfin.Data.Entities;
+
+/// <summary>
+/// Lists types of Audio.
+/// </summary>
+public enum ProgramAudioEntity
+{
+    /// <summary>
+    /// Mono.
+    /// </summary>
+    Mono,
+
+    /// <summary>
+    /// Sterio.
+    /// </summary>
+    Stereo,
+
+    /// <summary>
+    /// Dolby.
+    /// </summary>
+    Dolby,
+
+    /// <summary>
+    /// DolbyDigital.
+    /// </summary>
+    DolbyDigital,
+
+    /// <summary>
+    /// Thx.
+    /// </summary>
+    Thx,
+
+    /// <summary>
+    /// Atmos.
+    /// </summary>
+    Atmos
+}

+ 83 - 233
Jellyfin.Server.Implementations/Item/BaseItemRepository.cs

@@ -7,6 +7,7 @@ using System.Linq;
 using System.Linq.Expressions;
 using System.Text;
 using System.Threading;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
 using MediaBrowser.Controller;
@@ -69,6 +70,8 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
         context.AncestorIds.Where(e => e.ItemId == id).ExecuteDelete();
         context.ItemValues.Where(e => e.ItemId == id).ExecuteDelete();
         context.BaseItems.Where(e => e.Id == id).ExecuteDelete();
+        context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete();
+        context.BaseItemProviders.Where(e => e.ItemId == id).ExecuteDelete();
         context.SaveChanges();
         transaction.Commit();
     }
@@ -229,7 +232,12 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
         var result = new QueryResult<BaseItemDto>();
 
         using var context = dbProvider.CreateDbContext();
-        var dbQuery = TranslateQuery(context.BaseItems, context, filter)
+        IQueryable<BaseItemEntity> dbQuery = context.BaseItems
+            .Include(e => e.ExtraType)
+            .Include(e => e.TrailerTypes)
+            .Include(e => e.Images)
+            .Include(e => e.LockedFields);
+        dbQuery = TranslateQuery(dbQuery, context, filter)
             .DistinctBy(e => e.Id);
         if (filter.EnableTotalRecordCount)
         {
@@ -585,8 +593,8 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
 
         if (filter.TrailerTypes.Length > 0)
         {
-            var trailerTypes = filter.TrailerTypes.Select(e => e.ToString()).ToArray();
-            baseQuery = baseQuery.Where(e => trailerTypes.Any(f => e.TrailerTypes!.Contains(f)));
+            var trailerTypes = filter.TrailerTypes.Select(e => (int)e).ToArray();
+            baseQuery = baseQuery.Where(e => trailerTypes.Any(f => e.TrailerTypes!.Any(w => w.Id == f)));
         }
 
         if (filter.IsAiring.HasValue)
@@ -666,8 +674,8 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
 
         if (filter.ImageTypes.Length > 0)
         {
-            var imgTypes = filter.ImageTypes.Select(e => e.ToString()).ToArray();
-            baseQuery = baseQuery.Where(e => imgTypes.Any(f => e.Images!.Contains(f)));
+            var imgTypes = filter.ImageTypes.Select(e => (ImageInfoImageType)e).ToArray();
+            baseQuery = baseQuery.Where(e => imgTypes.Any(f => e.Images!.Any(w => w.ImageType == f)));
         }
 
         if (filter.IsLiked.HasValue)
@@ -1206,12 +1214,12 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
     {
         ArgumentNullException.ThrowIfNull(item);
 
-        var images = SerializeImages(item.ImageInfos);
+        var images = item.ImageInfos.Select(e => Map(item.Id, e));
         using var db = dbProvider.CreateDbContext();
-
-        db.BaseItems
-            .Where(e => e.Id == item.Id)
-            .ExecuteUpdate(e => e.SetProperty(f => f.Images, images));
+        using var transaction = db.Database.BeginTransaction();
+        db.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete();
+        db.BaseItemImageInfos.AddRange(images);
+        transaction.Commit();
     }
 
     /// <inheritdoc cref="IItemRepository" />
@@ -1260,29 +1268,32 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
             context.AncestorIds.Where(e => e.ItemId == entity.Id).ExecuteDelete();
             if (item.Item.SupportsAncestors && item.AncestorIds != null)
             {
+                entity.AncestorIds = new List<AncestorId>();
                 foreach (var ancestorId in item.AncestorIds)
                 {
-                    context.AncestorIds.Add(new Data.Entities.AncestorId()
+                    entity.AncestorIds.Add(new AncestorId()
                     {
                         Item = entity,
                         AncestorIdText = ancestorId.ToString(),
                         Id = ancestorId,
-                        ItemId = Guid.Empty
+                        ItemId = entity.Id
                     });
                 }
             }
 
             var itemValues = GetItemValuesToSave(item.Item, item.InheritedTags);
             context.ItemValues.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+            entity.ItemValues = new List<ItemValue>();
+
             foreach (var itemValue in itemValues)
             {
-                context.ItemValues.Add(new()
+                entity.ItemValues.Add(new()
                 {
                     Item = entity,
                     Type = itemValue.MagicNumber,
                     Value = itemValue.Value,
                     CleanValue = GetCleanValue(itemValue.Value),
-                    ItemId = Guid.Empty
+                    ItemId = entity.Id
                 });
             }
         }
@@ -1366,26 +1377,17 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
 
         if (entity.ExtraType is not null)
         {
-            dto.ExtraType = Enum.Parse<ExtraType>(entity.ExtraType);
+            dto.ExtraType = (ExtraType)entity.ExtraType;
         }
 
         if (entity.LockedFields is not null)
         {
-            List<MetadataField>? fields = null;
-            foreach (var i in entity.LockedFields.AsSpan().Split('|'))
-            {
-                if (Enum.TryParse(i, true, out MetadataField parsedValue))
-                {
-                    (fields ??= new List<MetadataField>()).Add(parsedValue);
-                }
-            }
-
-            dto.LockedFields = fields?.ToArray() ?? Array.Empty<MetadataField>();
+            dto.LockedFields = entity.LockedFields?.Select(e => (MetadataField)e.Id).ToArray() ?? [];
         }
 
         if (entity.Audio is not null)
         {
-            dto.Audio = Enum.Parse<ProgramAudio>(entity.Audio);
+            dto.Audio = (ProgramAudio)entity.Audio;
         }
 
         dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? null : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
@@ -1408,16 +1410,7 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
 
         if (dto is Trailer trailer)
         {
-            List<TrailerType>? types = null;
-            foreach (var i in entity.TrailerTypes.AsSpan().Split('|'))
-            {
-                if (Enum.TryParse(i, true, out TrailerType parsedValue))
-                {
-                    (types ??= new List<TrailerType>()).Add(parsedValue);
-                }
-            }
-
-            trailer.TrailerTypes = types?.ToArray() ?? Array.Empty<TrailerType>();
+            trailer.TrailerTypes = entity.TrailerTypes?.Select(e => (TrailerType)e.Id).ToArray() ?? [];
         }
 
         if (dto is Video video)
@@ -1455,7 +1448,7 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
 
         if (entity.Images is not null)
         {
-            dto.ImageInfos = DeserializeImages(entity.Images);
+            dto.ImageInfos = entity.Images.Select(Map).ToArray();
         }
 
         // dto.Type = entity.Type;
@@ -1490,8 +1483,8 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
         var entity = new BaseItemEntity()
         {
             Type = dto.GetType().ToString(),
+            Id = dto.Id
         };
-        entity.Id = dto.Id;
         entity.ParentId = dto.ParentId;
         entity.Path = GetPathToSave(dto.Path);
         entity.EndDate = dto.EndDate.GetValueOrDefault();
@@ -1533,21 +1526,35 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
         entity.OwnerId = dto.OwnerId.ToString();
         entity.Width = dto.Width;
         entity.Height = dto.Height;
-        entity.Provider = dto.ProviderIds.Select(e => new Data.Entities.BaseItemProvider()
+        entity.Provider = dto.ProviderIds.Select(e => new BaseItemProvider()
         {
             Item = entity,
             ProviderId = e.Key,
             ProviderValue = e.Value
         }).ToList();
 
-        entity.Audio = dto.Audio?.ToString();
-        entity.ExtraType = dto.ExtraType?.ToString();
+        if (dto.Audio.HasValue)
+        {
+            entity.Audio = (ProgramAudioEntity)dto.Audio;
+        }
+
+        if (dto.ExtraType.HasValue)
+        {
+            entity.ExtraType = (BaseItemExtraType)dto.ExtraType;
+        }
 
         entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
         entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
         entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
         entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
-        entity.LockedFields = dto.LockedFields is not null ? string.Join('|', dto.LockedFields) : null;
+        entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
+            .Select(e => new BaseItemMetadataField()
+            {
+                Id = (int)e,
+                Item = entity,
+                ItemId = entity.Id
+            })
+            .ToArray() : null;
 
         if (dto is IHasProgramAttributes hasProgramAttributes)
         {
@@ -1562,11 +1569,6 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
             entity.ExternalServiceId = liveTvChannel.ServiceName;
         }
 
-        if (dto is Trailer trailer)
-        {
-            entity.LockedFields = trailer.LockedFields is not null ? string.Join('|', trailer.LockedFields) : null;
-        }
-
         if (dto is Video video)
         {
             entity.PrimaryVersionId = video.PrimaryVersionId;
@@ -1602,7 +1604,17 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
 
         if (dto.ImageInfos is not null)
         {
-            entity.Images = SerializeImages(dto.ImageInfos);
+            entity.Images = dto.ImageInfos.Select(f => Map(dto.Id, f)).ToArray();
+        }
+
+        if (dto is Trailer trailer)
+        {
+            entity.TrailerTypes = trailer.TrailerTypes?.Select(e => new BaseItemTrailerType()
+            {
+                Id = (int)e,
+                Item = entity,
+                ItemId = entity.Id
+            }).ToArray() ?? [];
         }
 
         // dto.Type = entity.Type;
@@ -1863,90 +1875,33 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
         }
     }
 
-    internal string? SerializeImages(ItemImageInfo[] images)
-    {
-        if (images.Length == 0)
-        {
-            return null;
-        }
-
-        StringBuilder str = new StringBuilder();
-        foreach (var i in images)
-        {
-            if (string.IsNullOrWhiteSpace(i.Path))
-            {
-                continue;
-            }
-
-            AppendItemImageInfo(str, i);
-            str.Append('|');
-        }
-
-        str.Length -= 1; // Remove last |
-        return str.ToString();
-    }
-
-    internal ItemImageInfo[] DeserializeImages(string value)
+    private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e)
     {
-        if (string.IsNullOrWhiteSpace(value))
-        {
-            return Array.Empty<ItemImageInfo>();
-        }
-
-        // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed
-        var valueSpan = value.AsSpan();
-        var count = valueSpan.Count('|') + 1;
-
-        var position = 0;
-        var result = new ItemImageInfo[count];
-        foreach (var part in valueSpan.Split('|'))
-        {
-            var image = ItemImageInfoFromValueString(part);
-
-            if (image is not null)
-            {
-                result[position++] = image;
-            }
-        }
-
-        if (position == count)
-        {
-            return result;
-        }
-
-        if (position == 0)
-        {
-            return Array.Empty<ItemImageInfo>();
-        }
-
-        // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array.
-        return result[..position];
+        return new BaseItemImageInfo()
+        {
+            ItemId = baseItemId,
+            Id = Guid.NewGuid(),
+            Path = e.Path,
+            Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
+            DateModified = e.DateModified,
+            Height = e.Height,
+            Width = e.Width,
+            ImageType = (ImageInfoImageType)e.Type,
+            Item = null!
+        };
     }
 
-    private void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image)
+    private static ItemImageInfo Map(BaseItemImageInfo e)
     {
-        const char Delimiter = '*';
-
-        var path = image.Path ?? string.Empty;
-
-        bldr.Append(GetPathToSave(path))
-            .Append(Delimiter)
-            .Append(image.DateModified.Ticks)
-            .Append(Delimiter)
-            .Append(image.Type)
-            .Append(Delimiter)
-            .Append(image.Width)
-            .Append(Delimiter)
-            .Append(image.Height);
-
-        var hash = image.BlurHash;
-        if (!string.IsNullOrEmpty(hash))
-        {
-            bldr.Append(Delimiter)
-                // Replace delimiters with other characters.
-                // This can be removed when we migrate to a proper DB.
-                .Append(hash.Replace(Delimiter, '/').Replace('|', '\\'));
-        }
+        return new ItemImageInfo()
+        {
+            Path = e.Path,
+            BlurHash = e.Blurhash != null ? Encoding.UTF8.GetString(e.Blurhash) : null,
+            DateModified = e.DateModified,
+            Height = e.Height,
+            Width = e.Width,
+            Type = (ImageType)e.ImageType
+        };
     }
 
     private string? GetPathToSave(string path)
@@ -1964,111 +1919,6 @@ public sealed class BaseItemRepository(IDbContextFactory<JellyfinDbContext> dbPr
         return appHost.ExpandVirtualPath(path);
     }
 
-    internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan<char> value)
-    {
-        const char Delimiter = '*';
-
-        var nextSegment = value.IndexOf(Delimiter);
-        if (nextSegment == -1)
-        {
-            return null;
-        }
-
-        ReadOnlySpan<char> path = value[..nextSegment];
-        value = value[(nextSegment + 1)..];
-        nextSegment = value.IndexOf(Delimiter);
-        if (nextSegment == -1)
-        {
-            return null;
-        }
-
-        ReadOnlySpan<char> dateModified = value[..nextSegment];
-        value = value[(nextSegment + 1)..];
-        nextSegment = value.IndexOf(Delimiter);
-        if (nextSegment == -1)
-        {
-            nextSegment = value.Length;
-        }
-
-        ReadOnlySpan<char> imageType = value[..nextSegment];
-
-        var image = new ItemImageInfo
-        {
-            Path = RestorePath(path.ToString())
-        };
-
-        if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks)
-            && ticks >= DateTime.MinValue.Ticks
-            && ticks <= DateTime.MaxValue.Ticks)
-        {
-            image.DateModified = new DateTime(ticks, DateTimeKind.Utc);
-        }
-        else
-        {
-            return null;
-        }
-
-        if (Enum.TryParse(imageType, true, out ImageType type))
-        {
-            image.Type = type;
-        }
-        else
-        {
-            return null;
-        }
-
-        // Optional parameters: width*height*blurhash
-        if (nextSegment + 1 < value.Length - 1)
-        {
-            value = value[(nextSegment + 1)..];
-            nextSegment = value.IndexOf(Delimiter);
-            if (nextSegment == -1 || nextSegment == value.Length)
-            {
-                return image;
-            }
-
-            ReadOnlySpan<char> widthSpan = value[..nextSegment];
-
-            value = value[(nextSegment + 1)..];
-            nextSegment = value.IndexOf(Delimiter);
-            if (nextSegment == -1)
-            {
-                nextSegment = value.Length;
-            }
-
-            ReadOnlySpan<char> heightSpan = value[..nextSegment];
-
-            if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width)
-                && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height))
-            {
-                image.Width = width;
-                image.Height = height;
-            }
-
-            if (nextSegment < value.Length - 1)
-            {
-                value = value[(nextSegment + 1)..];
-                var length = value.Length;
-
-                Span<char> blurHashSpan = stackalloc char[length];
-                for (int i = 0; i < length; i++)
-                {
-                    var c = value[i];
-                    blurHashSpan[i] = c switch
-                    {
-                        '/' => Delimiter,
-                        '\\' => '|',
-                        _ => c
-                    };
-                }
-
-                image.BlurHash = new string(blurHashSpan);
-            }
-        }
-
-        return image;
-    }
-
     private List<string> GetItemByNameTypesInQuery(InternalItemsQuery query)
     {
         var list = new List<string>();

+ 15 - 0
Jellyfin.Server.Implementations/JellyfinDbContext.cs

@@ -131,6 +131,21 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
     /// </summary>
     public DbSet<BaseItemProvider> BaseItemProviders => Set<BaseItemProvider>();
 
+    /// <summary>
+    /// Gets the <see cref="DbSet{TEntity}"/>.
+    /// </summary>
+    public DbSet<BaseItemImageInfo> BaseItemImageInfos => Set<BaseItemImageInfo>();
+
+    /// <summary>
+    /// Gets the <see cref="DbSet{TEntity}"/>.
+    /// </summary>
+    public DbSet<BaseItemMetadataField> BaseItemMetadataFields => Set<BaseItemMetadataField>();
+
+    /// <summary>
+    /// Gets the <see cref="DbSet{TEntity}"/>.
+    /// </summary>
+    public DbSet<BaseItemTrailerType> BaseItemTrailerTypes => Set<BaseItemTrailerType>();
+
     /*public DbSet<Artwork> Artwork => Set<Artwork>();
 
     public DbSet<Book> Books => Set<Book>();

+ 1540 - 0
Jellyfin.Server.Implementations/Migrations/20241009225800_ExpandedBaseItemFields.Designer.cs

@@ -0,0 +1,1540 @@
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    [DbContext(typeof(JellyfinDbContext))]
+    [Migration("20241009225800_ExpandedBaseItemFields")]
+    partial class ExpandedBaseItemFields
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DayOfWeek")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<double>("EndHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<double>("StartHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AccessSchedules");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ItemId")
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("LogSeverity")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Overview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ShortOverview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DateCreated");
+
+                    b.ToTable("ActivityLogs");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("Id")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AncestorIdText")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemId", "Id");
+
+                    b.HasIndex("Id");
+
+                    b.HasIndex("ItemId", "AncestorIdText");
+
+                    b.ToTable("AncestorIds");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Index")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Codec")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CodecTag")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Comment")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Filename")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("MimeType")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemId", "Index");
+
+                    b.ToTable("AttachmentStreamInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Album")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AlbumArtists")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Artists")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Audio")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ChannelId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CleanName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float?>("CommunityRating")
+                        .HasColumnType("REAL");
+
+                    b.Property<float?>("CriticRating")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("CustomRating")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Data")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateLastMediaAdded")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateLastRefreshed")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateLastSaved")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("EndDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("EpisodeTitle")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExternalId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExternalSeriesId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExternalServiceId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ExtraIds")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ExtraType")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ForcedSortName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Genres")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("IndexNumber")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("InheritedParentalRatingValue")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsFolder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsInMixedFolder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsLocked")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsMovie")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsRepeat")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsSeries")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsVirtualItem")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<float?>("LUFS")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("MediaType")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float?>("NormalizationGain")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("OfficialRating")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("OriginalTitle")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Overview")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("OwnerId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("ParentId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ParentIndexNumber")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Path")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PreferredMetadataCountryCode")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PreferredMetadataLanguage")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("PremiereDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PresentationUniqueKey")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PrimaryVersionId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ProductionLocations")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ProductionYear")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long?>("RunTimeTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("SeasonId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SeasonName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("SeriesId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SeriesName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SeriesPresentationUniqueKey")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ShowId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<long?>("Size")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SortName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("StartDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Studios")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Tagline")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Tags")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("TopParentId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("TotalBitrate")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("UnratedType")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("UserDataKey")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ParentId");
+
+                    b.HasIndex("Path");
+
+                    b.HasIndex("PresentationUniqueKey");
+
+                    b.HasIndex("TopParentId", "Id");
+
+                    b.HasIndex("UserDataKey", "Type");
+
+                    b.HasIndex("Type", "TopParentId", "Id");
+
+                    b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+                    b.HasIndex("Type", "TopParentId", "StartDate");
+
+                    b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+                    b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+                    b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+                    b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+                    b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+                    b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+                    b.ToTable("BaseItems");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<byte[]>("Blurhash")
+                        .HasColumnType("BLOB");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ImageType")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b =>
+                {
+                    b.Property<int>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id", "ItemId");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemMetadataFields");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ProviderId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ProviderValue")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemId", "ProviderId");
+
+                    b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+                    b.ToTable("BaseItemProviders");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b =>
+                {
+                    b.Property<int>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id", "ItemId");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemTrailerTypes");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ChapterIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("ImageDateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ImagePath")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("StartPositionTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "ChapterIndex");
+
+                    b.ToTable("Chapters");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Key")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client", "Key")
+                        .IsUnique();
+
+                    b.ToTable("CustomItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ChromecastVersion")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DashboardTheme")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("EnableNextVideoInfoOverlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ScrollDirection")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowBackdrop")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowSidebar")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipBackwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipForwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("TvHome")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client")
+                        .IsUnique();
+
+                    b.ToTable("DisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DisplayPreferencesId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Order")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DisplayPreferencesId");
+
+                    b.ToTable("HomeSection");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("LastModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId")
+                        .IsUnique();
+
+                    b.ToTable("ImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("RememberIndexing")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSorting")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SortBy")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ViewType")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("ItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Value")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CleanValue")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("ItemId", "Type", "Value");
+
+                    b.HasIndex("ItemId", "Type", "CleanValue");
+
+                    b.ToTable("ItemValues");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("EndTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SegmentProviderId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("StartTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("MediaSegments");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("StreamIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AspectRatio")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float>("AverageFrameRate")
+                        .HasColumnType("REAL");
+
+                    b.Property<int>("BitDepth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("BitRate")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("BlPresentFlag")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ChannelLayout")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Channels")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Codec")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CodecTag")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CodecTimeBase")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ColorPrimaries")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ColorSpace")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ColorTransfer")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Comment")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("DvBlSignalCompatibilityId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DvLevel")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DvProfile")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DvVersionMajor")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DvVersionMinor")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ElPresentFlag")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsAnamorphic")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsAvc")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsDefault")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsExternal")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsForced")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsHearingImpaired")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsInterlaced")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("KeyFrames")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Language")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float>("Level")
+                        .HasColumnType("REAL");
+
+                    b.Property<string>("NalLengthSize")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PixelFormat")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Profile")
+                        .HasColumnType("TEXT");
+
+                    b.Property<float>("RealFrameRate")
+                        .HasColumnType("REAL");
+
+                    b.Property<int>("RefFrames")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Rotation")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("RpuPresentFlag")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SampleRate")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("StreamType")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("TimeBase")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Title")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "StreamIndex");
+
+                    b.HasIndex("StreamIndex");
+
+                    b.HasIndex("StreamType");
+
+                    b.HasIndex("StreamIndex", "StreamType");
+
+                    b.HasIndex("StreamIndex", "StreamType", "Language");
+
+                    b.ToTable("MediaStreamInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.People", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Role")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("ListOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PersonType")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "Role", "ListOrder");
+
+                    b.HasIndex("Name");
+
+                    b.HasIndex("ItemId", "ListOrder");
+
+                    b.ToTable("Peoples");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Permission_Permissions_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("Value")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Permissions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Preference_Preferences_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Preferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("AccessToken")
+                        .IsUnique();
+
+                    b.ToTable("ApiKeys");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppVersion")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("IsActive")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId");
+
+                    b.HasIndex("AccessToken", "DateLastActivity");
+
+                    b.HasIndex("DeviceId", "DateLastActivity");
+
+                    b.HasIndex("UserId", "DeviceId");
+
+                    b.ToTable("Devices");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("CustomName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId")
+                        .IsUnique();
+
+                    b.ToTable("DeviceOptions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Bandwidth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Interval")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ThumbnailCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileHeight")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileWidth")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "Width");
+
+                    b.ToTable("TrickplayInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AudioLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AuthenticationProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CastReceiverId")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("DisplayCollectionsView")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("DisplayMissingEpisodes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableAutoLogin")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableLocalPassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableNextEpisodeAutoPlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableUserPreferenceAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("HidePlayedInLatest")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long>("InternalId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("InvalidLoginAttemptCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("LastActivityDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("LastLoginDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("LoginAttemptsBeforeLockout")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("MaxActiveSessions")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("MaxParentalAgeRating")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("MustUpdatePassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Password")
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PasswordResetProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("PlayDefaultAudioTrack")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberAudioSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSubtitleSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("RemoteClientBitrateLimit")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SubtitleLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SubtitleMode")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SyncPlayAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Username")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT")
+                        .UseCollation("NOCASE");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Username")
+                        .IsUnique();
+
+                    b.ToTable("Users");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b =>
+                {
+                    b.Property<string>("Key")
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("AudioStreamIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("BaseItemEntityId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("IsFavorite")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("LastPlayedDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool?>("Likes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("PlayCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long>("PlaybackPositionTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("Played")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<double?>("Rating")
+                        .HasColumnType("REAL");
+
+                    b.Property<int?>("SubtitleStreamIndex")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Key", "UserId");
+
+                    b.HasIndex("BaseItemEntityId");
+
+                    b.HasIndex("UserId");
+
+                    b.HasIndex("Key", "UserId", "IsFavorite");
+
+                    b.HasIndex("Key", "UserId", "LastPlayedDate");
+
+                    b.HasIndex("Key", "UserId", "PlaybackPositionTicks");
+
+                    b.HasIndex("Key", "UserId", "Played");
+
+                    b.ToTable("UserData");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("AccessSchedules")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("AncestorIds")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany()
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Images")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("LockedFields")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Provider")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("TrailerTypes")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Chapters")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("DisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+                        .WithMany("HomeSections")
+                        .HasForeignKey("DisplayPreferencesId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithOne("ProfileImage")
+                        .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("ItemDisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("ItemValues")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("MediaStreams")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.People", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Peoples")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Permissions")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Preferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", null)
+                        .WithMany("UserData")
+                        .HasForeignKey("BaseItemEntityId");
+
+                    b.HasOne("Jellyfin.Data.Entities.User", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b =>
+                {
+                    b.Navigation("AncestorIds");
+
+                    b.Navigation("Chapters");
+
+                    b.Navigation("Images");
+
+                    b.Navigation("ItemValues");
+
+                    b.Navigation("LockedFields");
+
+                    b.Navigation("MediaStreams");
+
+                    b.Navigation("Peoples");
+
+                    b.Navigation("Provider");
+
+                    b.Navigation("TrailerTypes");
+
+                    b.Navigation("UserData");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Navigation("HomeSections");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Navigation("AccessSchedules");
+
+                    b.Navigation("DisplayPreferences");
+
+                    b.Navigation("ItemDisplayPreferences");
+
+                    b.Navigation("Permissions");
+
+                    b.Navigation("Preferences");
+
+                    b.Navigation("ProfileImage");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}

+ 169 - 0
Jellyfin.Server.Implementations/Migrations/20241009225800_ExpandedBaseItemFields.cs

@@ -0,0 +1,169 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    /// <inheritdoc />
+    public partial class ExpandedBaseItemFields : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropColumn(
+                name: "Images",
+                table: "BaseItems");
+
+            migrationBuilder.DropColumn(
+                name: "LockedFields",
+                table: "BaseItems");
+
+            migrationBuilder.DropColumn(
+                name: "TrailerTypes",
+                table: "BaseItems");
+
+            migrationBuilder.AlterColumn<int>(
+                name: "ExtraType",
+                table: "BaseItems",
+                type: "INTEGER",
+                nullable: true,
+                oldClrType: typeof(string),
+                oldType: "TEXT",
+                oldNullable: true);
+
+            migrationBuilder.AlterColumn<int>(
+                name: "Audio",
+                table: "BaseItems",
+                type: "INTEGER",
+                nullable: true,
+                oldClrType: typeof(string),
+                oldType: "TEXT",
+                oldNullable: true);
+
+            migrationBuilder.CreateTable(
+                name: "BaseItemImageInfos",
+                columns: table => new
+                {
+                    Id = table.Column<Guid>(type: "TEXT", nullable: false),
+                    Path = table.Column<string>(type: "TEXT", nullable: false),
+                    DateModified = table.Column<DateTime>(type: "TEXT", nullable: false),
+                    ImageType = table.Column<int>(type: "INTEGER", nullable: false),
+                    Width = table.Column<int>(type: "INTEGER", nullable: false),
+                    Height = table.Column<int>(type: "INTEGER", nullable: false),
+                    Blurhash = table.Column<byte[]>(type: "BLOB", nullable: true),
+                    ItemId = table.Column<Guid>(type: "TEXT", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_BaseItemImageInfos", x => x.Id);
+                    table.ForeignKey(
+                        name: "FK_BaseItemImageInfos_BaseItems_ItemId",
+                        column: x => x.ItemId,
+                        principalTable: "BaseItems",
+                        principalColumn: "Id",
+                        onDelete: ReferentialAction.Cascade);
+                });
+
+            migrationBuilder.CreateTable(
+                name: "BaseItemMetadataFields",
+                columns: table => new
+                {
+                    Id = table.Column<int>(type: "INTEGER", nullable: false),
+                    ItemId = table.Column<Guid>(type: "TEXT", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_BaseItemMetadataFields", x => new { x.Id, x.ItemId });
+                    table.ForeignKey(
+                        name: "FK_BaseItemMetadataFields_BaseItems_ItemId",
+                        column: x => x.ItemId,
+                        principalTable: "BaseItems",
+                        principalColumn: "Id",
+                        onDelete: ReferentialAction.Cascade);
+                });
+
+            migrationBuilder.CreateTable(
+                name: "BaseItemTrailerTypes",
+                columns: table => new
+                {
+                    Id = table.Column<int>(type: "INTEGER", nullable: false),
+                    ItemId = table.Column<Guid>(type: "TEXT", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_BaseItemTrailerTypes", x => new { x.Id, x.ItemId });
+                    table.ForeignKey(
+                        name: "FK_BaseItemTrailerTypes_BaseItems_ItemId",
+                        column: x => x.ItemId,
+                        principalTable: "BaseItems",
+                        principalColumn: "Id",
+                        onDelete: ReferentialAction.Cascade);
+                });
+
+            migrationBuilder.CreateIndex(
+                name: "IX_BaseItemImageInfos_ItemId",
+                table: "BaseItemImageInfos",
+                column: "ItemId");
+
+            migrationBuilder.CreateIndex(
+                name: "IX_BaseItemMetadataFields_ItemId",
+                table: "BaseItemMetadataFields",
+                column: "ItemId");
+
+            migrationBuilder.CreateIndex(
+                name: "IX_BaseItemTrailerTypes_ItemId",
+                table: "BaseItemTrailerTypes",
+                column: "ItemId");
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropTable(
+                name: "BaseItemImageInfos");
+
+            migrationBuilder.DropTable(
+                name: "BaseItemMetadataFields");
+
+            migrationBuilder.DropTable(
+                name: "BaseItemTrailerTypes");
+
+            migrationBuilder.AlterColumn<string>(
+                name: "ExtraType",
+                table: "BaseItems",
+                type: "TEXT",
+                nullable: true,
+                oldClrType: typeof(int),
+                oldType: "INTEGER",
+                oldNullable: true);
+
+            migrationBuilder.AlterColumn<string>(
+                name: "Audio",
+                table: "BaseItems",
+                type: "TEXT",
+                nullable: true,
+                oldClrType: typeof(int),
+                oldType: "INTEGER",
+                oldNullable: true);
+
+            migrationBuilder.AddColumn<string>(
+                name: "Images",
+                table: "BaseItems",
+                type: "TEXT",
+                nullable: true);
+
+            migrationBuilder.AddColumn<string>(
+                name: "LockedFields",
+                table: "BaseItems",
+                type: "TEXT",
+                nullable: true);
+
+            migrationBuilder.AddColumn<string>(
+                name: "TrailerTypes",
+                table: "BaseItems",
+                type: "TEXT",
+                nullable: true);
+        }
+    }
+}

+ 109 - 14
Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
         protected override void BuildModel(ModelBuilder modelBuilder)
         {
 #pragma warning disable 612, 618
-            modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
+            modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
 
             modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
                 {
@@ -154,8 +154,8 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Property<string>("Artists")
                         .HasColumnType("TEXT");
 
-                    b.Property<string>("Audio")
-                        .HasColumnType("TEXT");
+                    b.Property<int?>("Audio")
+                        .HasColumnType("INTEGER");
 
                     b.Property<string>("ChannelId")
                         .HasColumnType("TEXT");
@@ -208,8 +208,8 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Property<string>("ExtraIds")
                         .HasColumnType("TEXT");
 
-                    b.Property<string>("ExtraType")
-                        .HasColumnType("TEXT");
+                    b.Property<int?>("ExtraType")
+                        .HasColumnType("INTEGER");
 
                     b.Property<string>("ForcedSortName")
                         .HasColumnType("TEXT");
@@ -220,9 +220,6 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Property<int?>("Height")
                         .HasColumnType("INTEGER");
 
-                    b.Property<string>("Images")
-                        .HasColumnType("TEXT");
-
                     b.Property<int?>("IndexNumber")
                         .HasColumnType("INTEGER");
 
@@ -253,9 +250,6 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Property<float?>("LUFS")
                         .HasColumnType("REAL");
 
-                    b.Property<string>("LockedFields")
-                        .HasColumnType("TEXT");
-
                     b.Property<string>("MediaType")
                         .HasColumnType("TEXT");
 
@@ -352,9 +346,6 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Property<int?>("TotalBitrate")
                         .HasColumnType("INTEGER");
 
-                    b.Property<string>("TrailerTypes")
-                        .HasColumnType("TEXT");
-
                     b.Property<string>("Type")
                         .IsRequired()
                         .HasColumnType("TEXT");
@@ -401,6 +392,56 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.ToTable("BaseItems");
                 });
 
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<byte[]>("Blurhash")
+                        .HasColumnType("BLOB");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ImageType")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b =>
+                {
+                    b.Property<int>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id", "ItemId");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemMetadataFields");
+                });
+
             modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b =>
                 {
                     b.Property<Guid>("ItemId")
@@ -420,6 +461,21 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.ToTable("BaseItemProviders");
                 });
 
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b =>
+                {
+                    b.Property<int>("Id")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id", "ItemId");
+
+                    b.HasIndex("ItemId");
+
+                    b.ToTable("BaseItemTrailerTypes");
+                });
+
             modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b =>
                 {
                     b.Property<Guid>("ItemId")
@@ -1268,6 +1324,28 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Navigation("Item");
                 });
 
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("Images")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("LockedFields")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
             modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b =>
                 {
                     b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
@@ -1279,6 +1357,17 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Navigation("Item");
                 });
 
+            modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
+                        .WithMany("TrailerTypes")
+                        .HasForeignKey("ItemId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("Item");
+                });
+
             modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b =>
                 {
                     b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item")
@@ -1406,14 +1495,20 @@ namespace Jellyfin.Server.Implementations.Migrations
 
                     b.Navigation("Chapters");
 
+                    b.Navigation("Images");
+
                     b.Navigation("ItemValues");
 
+                    b.Navigation("LockedFields");
+
                     b.Navigation("MediaStreams");
 
                     b.Navigation("Peoples");
 
                     b.Navigation("Provider");
 
+                    b.Navigation("TrailerTypes");
+
                     b.Navigation("UserData");
                 });
 

+ 3 - 0
Jellyfin.Server.Implementations/ModelConfiguration/BaseItemConfiguration.cs

@@ -27,6 +27,9 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
         builder.HasMany(e => e.Chapters);
         builder.HasMany(e => e.Provider);
         builder.HasMany(e => e.AncestorIds);
+        builder.HasMany(e => e.LockedFields);
+        builder.HasMany(e => e.TrailerTypes);
+        builder.HasMany(e => e.Images);
 
         builder.HasIndex(e => e.Path);
         builder.HasIndex(e => e.ParentId);

+ 22 - 0
Jellyfin.Server.Implementations/ModelConfiguration/BaseItemMetadataFieldConfiguration.cs

@@ -0,0 +1,22 @@
+using System;
+using System.Linq;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Model.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using SQLitePCL;
+
+namespace Jellyfin.Server.Implementations.ModelConfiguration;
+
+/// <summary>
+/// Provides configuration for the BaseItemMetadataField entity.
+/// </summary>
+public class BaseItemMetadataFieldConfiguration : IEntityTypeConfiguration<BaseItemMetadataField>
+{
+    /// <inheritdoc/>
+    public void Configure(EntityTypeBuilder<BaseItemMetadataField> builder)
+    {
+        builder.HasKey(e => new { e.Id, e.ItemId });
+        builder.HasOne(e => e.Item);
+    }
+}

+ 22 - 0
Jellyfin.Server.Implementations/ModelConfiguration/BaseItemTrailerTypeConfiguration.cs

@@ -0,0 +1,22 @@
+using System;
+using System.Linq;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Model.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using SQLitePCL;
+
+namespace Jellyfin.Server.Implementations.ModelConfiguration;
+
+/// <summary>
+/// Provides configuration for the BaseItemMetadataField entity.
+/// </summary>
+public class BaseItemTrailerTypeConfiguration : IEntityTypeConfiguration<BaseItemTrailerType>
+{
+    /// <inheritdoc/>
+    public void Configure(EntityTypeBuilder<BaseItemTrailerType> builder)
+    {
+        builder.HasKey(e => new { e.Id, e.ItemId });
+        builder.HasOne(e => e.Item);
+    }
+}

+ 250 - 73
Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

@@ -2,13 +2,17 @@ using System;
 using System.Collections.Generic;
 using System.Collections.Immutable;
 using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
 using System.IO;
 using System.Linq;
+using System.Text;
 using Emby.Server.Implementations.Data;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities.Libraries;
+using Jellyfin.Extensions;
 using Jellyfin.Server.Implementations;
 using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.LiveTv;
 using Microsoft.Data.Sqlite;
@@ -503,293 +507,308 @@ public class MigrateLibraryDb : IMigrationRoutine
 
     private BaseItemEntity GetItem(SqliteDataReader reader)
     {
-        var item = new BaseItemEntity()
+        var entity = new BaseItemEntity()
         {
-            Type = reader.GetString(0)
+            Type = reader.GetString(0),
+            Id = Guid.NewGuid()
         };
 
         var index = 1;
 
         if (reader.TryGetString(index++, out var data))
         {
-            item.Data = data;
+            entity.Data = data;
         }
 
         if (reader.TryReadDateTime(index++, out var startDate))
         {
-            item.StartDate = startDate;
+            entity.StartDate = startDate;
         }
 
         if (reader.TryReadDateTime(index++, out var endDate))
         {
-            item.EndDate = endDate;
+            entity.EndDate = endDate;
         }
 
         if (reader.TryGetGuid(index++, out var guid))
         {
-            item.ChannelId = guid.ToString("N");
+            entity.ChannelId = guid.ToString("N");
         }
 
         if (reader.TryGetBoolean(index++, out var isMovie))
         {
-            item.IsMovie = isMovie;
+            entity.IsMovie = isMovie;
         }
 
         if (reader.TryGetBoolean(index++, out var isSeries))
         {
-            item.IsSeries = isSeries;
+            entity.IsSeries = isSeries;
         }
 
         if (reader.TryGetString(index++, out var episodeTitle))
         {
-            item.EpisodeTitle = episodeTitle;
+            entity.EpisodeTitle = episodeTitle;
         }
 
         if (reader.TryGetBoolean(index++, out var isRepeat))
         {
-            item.IsRepeat = isRepeat;
+            entity.IsRepeat = isRepeat;
         }
 
         if (reader.TryGetSingle(index++, out var communityRating))
         {
-            item.CommunityRating = communityRating;
+            entity.CommunityRating = communityRating;
         }
 
         if (reader.TryGetString(index++, out var customRating))
         {
-            item.CustomRating = customRating;
+            entity.CustomRating = customRating;
         }
 
         if (reader.TryGetInt32(index++, out var indexNumber))
         {
-            item.IndexNumber = indexNumber;
+            entity.IndexNumber = indexNumber;
         }
 
         if (reader.TryGetBoolean(index++, out var isLocked))
         {
-            item.IsLocked = isLocked;
+            entity.IsLocked = isLocked;
         }
 
         if (reader.TryGetString(index++, out var preferredMetadataLanguage))
         {
-            item.PreferredMetadataLanguage = preferredMetadataLanguage;
+            entity.PreferredMetadataLanguage = preferredMetadataLanguage;
         }
 
         if (reader.TryGetString(index++, out var preferredMetadataCountryCode))
         {
-            item.PreferredMetadataCountryCode = preferredMetadataCountryCode;
+            entity.PreferredMetadataCountryCode = preferredMetadataCountryCode;
         }
 
         if (reader.TryGetInt32(index++, out var width))
         {
-            item.Width = width;
+            entity.Width = width;
         }
 
         if (reader.TryGetInt32(index++, out var height))
         {
-            item.Height = height;
+            entity.Height = height;
         }
 
         if (reader.TryReadDateTime(index++, out var dateLastRefreshed))
         {
-            item.DateLastRefreshed = dateLastRefreshed;
+            entity.DateLastRefreshed = dateLastRefreshed;
         }
 
         if (reader.TryGetString(index++, out var name))
         {
-            item.Name = name;
+            entity.Name = name;
         }
 
         if (reader.TryGetString(index++, out var restorePath))
         {
-            item.Path = restorePath;
+            entity.Path = restorePath;
         }
 
         if (reader.TryReadDateTime(index++, out var premiereDate))
         {
-            item.PremiereDate = premiereDate;
+            entity.PremiereDate = premiereDate;
         }
 
         if (reader.TryGetString(index++, out var overview))
         {
-            item.Overview = overview;
+            entity.Overview = overview;
         }
 
         if (reader.TryGetInt32(index++, out var parentIndexNumber))
         {
-            item.ParentIndexNumber = parentIndexNumber;
+            entity.ParentIndexNumber = parentIndexNumber;
         }
 
         if (reader.TryGetInt32(index++, out var productionYear))
         {
-            item.ProductionYear = productionYear;
+            entity.ProductionYear = productionYear;
         }
 
         if (reader.TryGetString(index++, out var officialRating))
         {
-            item.OfficialRating = officialRating;
+            entity.OfficialRating = officialRating;
         }
 
         if (reader.TryGetString(index++, out var forcedSortName))
         {
-            item.ForcedSortName = forcedSortName;
+            entity.ForcedSortName = forcedSortName;
         }
 
         if (reader.TryGetInt64(index++, out var runTimeTicks))
         {
-            item.RunTimeTicks = runTimeTicks;
+            entity.RunTimeTicks = runTimeTicks;
         }
 
         if (reader.TryGetInt64(index++, out var size))
         {
-            item.Size = size;
+            entity.Size = size;
         }
 
         if (reader.TryReadDateTime(index++, out var dateCreated))
         {
-            item.DateCreated = dateCreated;
+            entity.DateCreated = dateCreated;
         }
 
         if (reader.TryReadDateTime(index++, out var dateModified))
         {
-            item.DateModified = dateModified;
+            entity.DateModified = dateModified;
         }
 
-        item.Id = reader.GetGuid(index++);
+        entity.Id = reader.GetGuid(index++);
 
         if (reader.TryGetString(index++, out var genres))
         {
-            item.Genres = genres;
+            entity.Genres = genres;
         }
 
         if (reader.TryGetGuid(index++, out var parentId))
         {
-            item.ParentId = parentId;
+            entity.ParentId = parentId;
         }
 
-        if (reader.TryGetString(index++, out var audioString))
+        if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var audioType))
         {
-            item.Audio = audioString;
+            entity.Audio = audioType;
         }
 
         if (reader.TryGetString(index++, out var serviceName))
         {
-            item.ExternalServiceId = serviceName;
+            entity.ExternalServiceId = serviceName;
         }
 
         if (reader.TryGetBoolean(index++, out var isInMixedFolder))
         {
-            item.IsInMixedFolder = isInMixedFolder;
+            entity.IsInMixedFolder = isInMixedFolder;
         }
 
         if (reader.TryReadDateTime(index++, out var dateLastSaved))
         {
-            item.DateLastSaved = dateLastSaved;
+            entity.DateLastSaved = dateLastSaved;
         }
 
         if (reader.TryGetString(index++, out var lockedFields))
         {
-            item.LockedFields = lockedFields;
+            entity.LockedFields = lockedFields.Split('|').Select(Enum.Parse<MetadataField>)
+                .Select(e => new BaseItemMetadataField()
+                {
+                    Id = (int)e,
+                    Item = entity,
+                    ItemId = entity.Id
+                })
+                .ToArray();
         }
 
         if (reader.TryGetString(index++, out var studios))
         {
-            item.Studios = studios;
+            entity.Studios = studios;
         }
 
         if (reader.TryGetString(index++, out var tags))
         {
-            item.Tags = tags;
+            entity.Tags = tags;
         }
 
         if (reader.TryGetString(index++, out var trailerTypes))
         {
-            item.TrailerTypes = trailerTypes;
+            entity.TrailerTypes = trailerTypes.Split('|').Select(Enum.Parse<TrailerType>)
+                .Select(e => new BaseItemTrailerType()
+                {
+                    Id = (int)e,
+                    Item = entity,
+                    ItemId = entity.Id
+                })
+                .ToArray();
         }
 
         if (reader.TryGetString(index++, out var originalTitle))
         {
-            item.OriginalTitle = originalTitle;
+            entity.OriginalTitle = originalTitle;
         }
 
         if (reader.TryGetString(index++, out var primaryVersionId))
         {
-            item.PrimaryVersionId = primaryVersionId;
+            entity.PrimaryVersionId = primaryVersionId;
         }
 
         if (reader.TryReadDateTime(index++, out var dateLastMediaAdded))
         {
-            item.DateLastMediaAdded = dateLastMediaAdded;
+            entity.DateLastMediaAdded = dateLastMediaAdded;
         }
 
         if (reader.TryGetString(index++, out var album))
         {
-            item.Album = album;
+            entity.Album = album;
         }
 
         if (reader.TryGetSingle(index++, out var lUFS))
         {
-            item.LUFS = lUFS;
+            entity.LUFS = lUFS;
         }
 
         if (reader.TryGetSingle(index++, out var normalizationGain))
         {
-            item.NormalizationGain = normalizationGain;
+            entity.NormalizationGain = normalizationGain;
         }
 
         if (reader.TryGetSingle(index++, out var criticRating))
         {
-            item.CriticRating = criticRating;
+            entity.CriticRating = criticRating;
         }
 
         if (reader.TryGetBoolean(index++, out var isVirtualItem))
         {
-            item.IsVirtualItem = isVirtualItem;
+            entity.IsVirtualItem = isVirtualItem;
         }
 
         if (reader.TryGetString(index++, out var seriesName))
         {
-            item.SeriesName = seriesName;
+            entity.SeriesName = seriesName;
         }
 
         if (reader.TryGetString(index++, out var seasonName))
         {
-            item.SeasonName = seasonName;
+            entity.SeasonName = seasonName;
         }
 
         if (reader.TryGetGuid(index++, out var seasonId))
         {
-            item.SeasonId = seasonId;
+            entity.SeasonId = seasonId;
         }
 
         if (reader.TryGetGuid(index++, out var seriesId))
         {
-            item.SeriesId = seriesId;
+            entity.SeriesId = seriesId;
         }
 
         if (reader.TryGetString(index++, out var presentationUniqueKey))
         {
-            item.PresentationUniqueKey = presentationUniqueKey;
+            entity.PresentationUniqueKey = presentationUniqueKey;
         }
 
         if (reader.TryGetInt32(index++, out var parentalRating))
         {
-            item.InheritedParentalRatingValue = parentalRating;
+            entity.InheritedParentalRatingValue = parentalRating;
         }
 
         if (reader.TryGetString(index++, out var externalSeriesId))
         {
-            item.ExternalSeriesId = externalSeriesId;
+            entity.ExternalSeriesId = externalSeriesId;
         }
 
         if (reader.TryGetString(index++, out var tagLine))
         {
-            item.Tagline = tagLine;
+            entity.Tagline = tagLine;
         }
 
         if (reader.TryGetString(index++, out var providerIds))
         {
-            item.Provider = providerIds.Split('|').Select(e => e.Split("="))
+            entity.Provider = providerIds.Split('|').Select(e => e.Split("="))
             .Select(e => new BaseItemProvider()
             {
                 Item = null!,
@@ -800,59 +819,217 @@ public class MigrateLibraryDb : IMigrationRoutine
 
         if (reader.TryGetString(index++, out var imageInfos))
         {
-            item.Images = imageInfos;
+            entity.Images = DeserializeImages(imageInfos).Select(f => Map(entity.Id, f)).ToArray();
         }
 
         if (reader.TryGetString(index++, out var productionLocations))
         {
-            item.ProductionLocations = productionLocations;
+            entity.ProductionLocations = productionLocations;
         }
 
         if (reader.TryGetString(index++, out var extraIds))
         {
-            item.ExtraIds = extraIds;
+            entity.ExtraIds = extraIds;
         }
 
         if (reader.TryGetInt32(index++, out var totalBitrate))
         {
-            item.TotalBitrate = totalBitrate;
+            entity.TotalBitrate = totalBitrate;
         }
 
-        if (reader.TryGetString(index++, out var extraTypeString))
+        if (reader.TryGetString(index++, out var extraTypeString) && Enum.TryParse<BaseItemExtraType>(extraTypeString, out var extraType))
         {
-            item.ExtraType = extraTypeString;
+            entity.ExtraType = extraType;
         }
 
         if (reader.TryGetString(index++, out var artists))
         {
-            item.Artists = artists;
+            entity.Artists = artists;
         }
 
         if (reader.TryGetString(index++, out var albumArtists))
         {
-            item.AlbumArtists = albumArtists;
+            entity.AlbumArtists = albumArtists;
         }
 
         if (reader.TryGetString(index++, out var externalId))
         {
-            item.ExternalId = externalId;
+            entity.ExternalId = externalId;
         }
 
         if (reader.TryGetString(index++, out var seriesPresentationUniqueKey))
         {
-            item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
+            entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
         }
 
         if (reader.TryGetString(index++, out var showId))
         {
-            item.ShowId = showId;
+            entity.ShowId = showId;
         }
 
         if (reader.TryGetGuid(index++, out var ownerId))
         {
-            item.OwnerId = ownerId.ToString("N");
+            entity.OwnerId = ownerId.ToString("N");
         }
 
-        return item;
+        return entity;
+    }
+
+    private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e)
+    {
+        return new BaseItemImageInfo()
+        {
+            ItemId = baseItemId,
+            Id = Guid.NewGuid(),
+            Path = e.Path,
+            Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
+            DateModified = e.DateModified,
+            Height = e.Height,
+            Width = e.Width,
+            ImageType = (ImageInfoImageType)e.Type,
+            Item = null!
+        };
+    }
+
+    internal ItemImageInfo[] DeserializeImages(string value)
+    {
+        if (string.IsNullOrWhiteSpace(value))
+        {
+            return Array.Empty<ItemImageInfo>();
+        }
+
+        // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed
+        var valueSpan = value.AsSpan();
+        var count = valueSpan.Count('|') + 1;
+
+        var position = 0;
+        var result = new ItemImageInfo[count];
+        foreach (var part in valueSpan.Split('|'))
+        {
+            var image = ItemImageInfoFromValueString(part);
+
+            if (image is not null)
+            {
+                result[position++] = image;
+            }
+        }
+
+        if (position == count)
+        {
+            return result;
+        }
+
+        if (position == 0)
+        {
+            return Array.Empty<ItemImageInfo>();
+        }
+
+        // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array.
+        return result[..position];
+    }
+
+    internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan<char> value)
+    {
+        const char Delimiter = '*';
+
+        var nextSegment = value.IndexOf(Delimiter);
+        if (nextSegment == -1)
+        {
+            return null;
+        }
+
+        ReadOnlySpan<char> path = value[..nextSegment];
+        value = value[(nextSegment + 1)..];
+        nextSegment = value.IndexOf(Delimiter);
+        if (nextSegment == -1)
+        {
+            return null;
+        }
+
+        ReadOnlySpan<char> dateModified = value[..nextSegment];
+        value = value[(nextSegment + 1)..];
+        nextSegment = value.IndexOf(Delimiter);
+        if (nextSegment == -1)
+        {
+            nextSegment = value.Length;
+        }
+
+        ReadOnlySpan<char> imageType = value[..nextSegment];
+
+        var image = new ItemImageInfo
+        {
+            Path = path.ToString()
+        };
+
+        if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks)
+            && ticks >= DateTime.MinValue.Ticks
+            && ticks <= DateTime.MaxValue.Ticks)
+        {
+            image.DateModified = new DateTime(ticks, DateTimeKind.Utc);
+        }
+        else
+        {
+            return null;
+        }
+
+        if (Enum.TryParse(imageType, true, out ImageType type))
+        {
+            image.Type = type;
+        }
+        else
+        {
+            return null;
+        }
+
+        // Optional parameters: width*height*blurhash
+        if (nextSegment + 1 < value.Length - 1)
+        {
+            value = value[(nextSegment + 1)..];
+            nextSegment = value.IndexOf(Delimiter);
+            if (nextSegment == -1 || nextSegment == value.Length)
+            {
+                return image;
+            }
+
+            ReadOnlySpan<char> widthSpan = value[..nextSegment];
+
+            value = value[(nextSegment + 1)..];
+            nextSegment = value.IndexOf(Delimiter);
+            if (nextSegment == -1)
+            {
+                nextSegment = value.Length;
+            }
+
+            ReadOnlySpan<char> heightSpan = value[..nextSegment];
+
+            if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width)
+                && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height))
+            {
+                image.Width = width;
+                image.Height = height;
+            }
+
+            if (nextSegment < value.Length - 1)
+            {
+                value = value[(nextSegment + 1)..];
+                var length = value.Length;
+
+                Span<char> blurHashSpan = stackalloc char[length];
+                for (int i = 0; i < length; i++)
+                {
+                    var c = value[i];
+                    blurHashSpan[i] = c switch
+                    {
+                        '/' => Delimiter,
+                        '\\' => '|',
+                        _ => c
+                    };
+                }
+
+                image.BlurHash = new string(blurHashSpan);
+            }
+        }
+
+        return image;
     }
 }

+ 0 - 66
tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs

@@ -99,31 +99,6 @@ namespace Jellyfin.Server.Implementations.Tests.Data
             return data;
         }
 
-        [Theory]
-        [MemberData(nameof(ItemImageInfoFromValueString_Valid_TestData))]
-        public void ItemImageInfoFromValueString_Valid_Success(string value, ItemImageInfo expected)
-        {
-            var result = _sqliteItemRepository.ItemImageInfoFromValueString(value)!;
-            Assert.Equal(expected.Path, result.Path);
-            Assert.Equal(expected.Type, result.Type);
-            Assert.Equal(expected.DateModified, result.DateModified);
-            Assert.Equal(expected.Width, result.Width);
-            Assert.Equal(expected.Height, result.Height);
-            Assert.Equal(expected.BlurHash, result.BlurHash);
-        }
-
-        [Theory]
-        [InlineData("")]
-        [InlineData("*")]
-        [InlineData("https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0")]
-        [InlineData("/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*6374520964785129080*WjQbtJtSO8nhNZ%L_Io#R/oaS<o}-;adXAoIn7j[%hW9s:WGw[nN")] // Invalid modified date
-        [InlineData("/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*-637452096478512963*WjQbtJtSO8nhNZ%L_Io#R/oaS<o}-;adXAoIn7j[%hW9s:WGw[nN")] // Negative modified date
-        [InlineData("/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Invalid*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN")] // Invalid type
-        public void ItemImageInfoFromValueString_Invalid_Null(string value)
-        {
-            Assert.Null(_sqliteItemRepository.ItemImageInfoFromValueString(value));
-        }
-
         public static TheoryData<string, ItemImageInfo[]> DeserializeImages_Valid_TestData()
         {
             var data = new TheoryData<string, ItemImageInfo[]>();
@@ -204,47 +179,6 @@ namespace Jellyfin.Server.Implementations.Tests.Data
             return data;
         }
 
-        [Theory]
-        [MemberData(nameof(DeserializeImages_Valid_TestData))]
-        public void DeserializeImages_Valid_Success(string value, ItemImageInfo[] expected)
-        {
-            var result = _sqliteItemRepository.DeserializeImages(value);
-            Assert.Equal(expected.Length, result.Length);
-            for (int i = 0; i < expected.Length; i++)
-            {
-                Assert.Equal(expected[i].Path, result[i].Path);
-                Assert.Equal(expected[i].Type, result[i].Type);
-                Assert.Equal(expected[i].DateModified, result[i].DateModified);
-                Assert.Equal(expected[i].Width, result[i].Width);
-                Assert.Equal(expected[i].Height, result[i].Height);
-                Assert.Equal(expected[i].BlurHash, result[i].BlurHash);
-            }
-        }
-
-        [Theory]
-        [MemberData(nameof(DeserializeImages_ValidAndInvalid_TestData))]
-        public void DeserializeImages_ValidAndInvalid_Success(string value, ItemImageInfo[] expected)
-        {
-            var result = _sqliteItemRepository.DeserializeImages(value);
-            Assert.Equal(expected.Length, result.Length);
-            for (int i = 0; i < expected.Length; i++)
-            {
-                Assert.Equal(expected[i].Path, result[i].Path);
-                Assert.Equal(expected[i].Type, result[i].Type);
-                Assert.Equal(expected[i].DateModified, result[i].DateModified);
-                Assert.Equal(expected[i].Width, result[i].Width);
-                Assert.Equal(expected[i].Height, result[i].Height);
-                Assert.Equal(expected[i].BlurHash, result[i].BlurHash);
-            }
-        }
-
-        [Theory]
-        [MemberData(nameof(DeserializeImages_Valid_TestData))]
-        public void SerializeImages_Valid_Success(string expected, ItemImageInfo[] value)
-        {
-            Assert.Equal(expected, _sqliteItemRepository.SerializeImages(value));
-        }
-
         private sealed class ProviderIdsExtensionsTestsObject : IHasProviderIds
         {
             public Dictionary<string, string> ProviderIds { get; set; } = new Dictionary<string, string>();