aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs69
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs3
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs1805
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs120
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs24
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs23
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs181
11 files changed, 2173 insertions, 59 deletions
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 932ced547a..fea6084267 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -225,19 +225,8 @@ namespace Jellyfin.Server.Implementations.Users
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
- dbContext.Permissions.RemoveRange(dbUser.Permissions);
- dbUser.Permissions.Clear();
- foreach (var permission in user.Permissions)
- {
- dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
- }
-
- dbContext.Preferences.RemoveRange(dbUser.Preferences);
- dbUser.Preferences.Clear();
- foreach (var preference in user.Preferences)
- {
- dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
- }
+ SyncPermissions(dbUser, user.Permissions);
+ SyncPreferences(dbUser, user.Preferences);
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
@@ -271,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users
}
}
+ private static void SyncPermissions(User dbUser, ICollection<Permission> source)
+ {
+ var incoming = new Dictionary<PermissionKind, bool>();
+ foreach (var permission in source)
+ {
+ incoming[permission.Kind] = permission.Value;
+ }
+
+ foreach (var existing in dbUser.Permissions)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ // EF only marks the row modified if the value actually differs, so an update that
+ // touches nothing but the user row - a session activity stamp - writes no children.
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Permissions.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Permissions.Add(new Permission(kind, value));
+ }
+ }
+
+ private static void SyncPreferences(User dbUser, ICollection<Preference> source)
+ {
+ var incoming = new Dictionary<PreferenceKind, string>();
+ foreach (var preference in source)
+ {
+ incoming[preference.Kind] = preference.Value;
+ }
+
+ foreach (var existing in dbUser.Preferences)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Preferences.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Preferences.Add(new Preference(kind, value));
+ }
+ }
+
internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
{
// TODO: Remove after user item data is migrated.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
index 84b86574cc..eae02dda1c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
@@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets or sets the id of the associated user.
/// </summary>
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
/// <summary>
/// Gets the type of this permission.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
index c02ea7375a..9bd159f2cf 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
@@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets or sets the id of the associated user.
/// </summary>
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
/// <summary>
/// Gets the type of this preference.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
index b10e210e5d..bf6568d10a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Interfaces;
@@ -326,7 +325,6 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets the list of permissions this user has.
/// </summary>
- [ForeignKey("Permission_Permissions_Guid")]
public virtual ICollection<Permission> Permissions { get; private set; }
/*
@@ -339,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets the list of preferences this user has.
/// </summary>
- [ForeignKey("Preference_Preferences_Guid")]
public virtual ICollection<Preference> Preferences { get; private set; }
/// <inheritdoc/>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
index d2aed54eb1..ae53a36724 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
@@ -14,10 +14,8 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
// Used to get a user's permissions or a specific permission for a user.
// Also prevents multiple values being created for a user.
- // Filtered over non-null user ids for when other entities (groups, API keys) get permissions
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
index 207051bcd1..5306078ed4 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs
new file mode 100644
index 0000000000..9c5d2cb36c
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs
@@ -0,0 +1,1805 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20260815063607_RemoveOrphanedUserPermissionsAndPreferences")]
+ partial class RemoveOrphanedUserPermissionsAndPreferences
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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<Guid?>("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<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?>("InheritedParentalRatingSubValue")
+ .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>("OriginalLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("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<Guid?>("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<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("PrimaryVersionId")
+ .HasFilter("\"PrimaryVersionId\" IS NOT NULL");
+
+ b.HasIndex("SeasonId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("SeriesName");
+
+ b.HasIndex("ExtraType", "OwnerId");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "CleanName");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem")
+ .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "SortName");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detached from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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", "ImageType");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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", "ItemId", "ProviderValue");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.Property<Guid>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ChildId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChildType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ParentId", "SortOrder");
+
+ b.HasIndex("ChildId", "ChildType");
+
+ b.HasIndex("ParentId", "ChildType");
+
+ b.ToTable("LinkedChildren", (string)null);
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .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<bool?>("Hdr10PlusPresentFlag")
+ .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<bool>("IsOriginal")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .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<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId", "Role");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.HasIndex("PeopleId", "ItemId");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ 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();
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ 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();
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("NormalizedUsername")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ 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");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedUsername")
+ .IsUnique();
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ 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<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.HasIndex("UserId", "IsFavorite", "ItemId");
+
+ b.HasIndex("UserId", "ItemId", "LastPlayedDate");
+
+ b.HasIndex("UserId", "Played", "ItemId");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner")
+ .WithMany("Extras")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.NoAction);
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent")
+ .WithMany("DirectChildren")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("DirectParent");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child")
+ .WithMany("LinkedChildOfEntities")
+ .HasForeignKey("ChildId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent")
+ .WithMany("LinkedChildEntities")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.Navigation("Child");
+
+ b.Navigation("Parent");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("DirectChildren");
+
+ b.Navigation("Extras");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LinkedChildEntities");
+
+ b.Navigation("LinkedChildOfEntities");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.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
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs
new file mode 100644
index 0000000000..3d4cf90441
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs
@@ -0,0 +1,120 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ /// <inheritdoc />
+ public partial class RemoveOrphanedUserPermissionsAndPreferences : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL;");
+ migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL;");
+
+ migrationBuilder.DropIndex(
+ name: "IX_Preferences_UserId_Kind",
+ table: "Preferences");
+
+ migrationBuilder.DropIndex(
+ name: "IX_Permissions_UserId_Kind",
+ table: "Permissions");
+
+ migrationBuilder.DropColumn(
+ name: "Preference_Preferences_Guid",
+ table: "Preferences");
+
+ migrationBuilder.DropColumn(
+ name: "Permission_Permissions_Guid",
+ table: "Permissions");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "UserId",
+ table: "Preferences",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "TEXT",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "UserId",
+ table: "Permissions",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "TEXT",
+ oldNullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Preferences_UserId_Kind",
+ table: "Preferences",
+ columns: ["UserId", "Kind"],
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Permissions_UserId_Kind",
+ table: "Permissions",
+ columns: ["UserId", "Kind"],
+ unique: true);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Preferences_UserId_Kind",
+ table: "Preferences");
+
+ migrationBuilder.DropIndex(
+ name: "IX_Permissions_UserId_Kind",
+ table: "Permissions");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "UserId",
+ table: "Preferences",
+ type: "TEXT",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "TEXT");
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "Preference_Preferences_Guid",
+ table: "Preferences",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "UserId",
+ table: "Permissions",
+ type: "TEXT",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "TEXT");
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "Permission_Permissions_Guid",
+ table: "Permissions",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Preferences_UserId_Kind",
+ table: "Preferences",
+ columns: ["UserId", "Kind"],
+ unique: true,
+ filter: "[UserId] IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Permissions_UserId_Kind",
+ table: "Permissions",
+ columns: ["UserId", "Kind"],
+ unique: true,
+ filter: "[UserId] IS NOT NULL");
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
index cdf5c84826..35a62d4907 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/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", "10.0.10");
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -1078,14 +1078,11 @@ namespace Jellyfin.Server.Implementations.Migrations
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")
+ b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Value")
@@ -1094,8 +1091,7 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id");
b.HasIndex("UserId", "Kind")
- .IsUnique()
- .HasFilter("[UserId] IS NOT NULL");
+ .IsUnique();
b.ToTable("Permissions");
@@ -1111,14 +1107,11 @@ namespace Jellyfin.Server.Implementations.Migrations
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")
+ b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<string>("Value")
@@ -1129,8 +1122,7 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id");
b.HasIndex("UserId", "Kind")
- .IsUnique()
- .HasFilter("[UserId] IS NOT NULL");
+ .IsUnique();
b.ToTable("Preferences");
@@ -1699,7 +1691,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
.WithMany("Permissions")
.HasForeignKey("UserId")
- .OnDelete(DeleteBehavior.Cascade);
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
@@ -1707,7 +1700,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
.WithMany("Preferences")
.HasForeignKey("UserId")
- .OnDelete(DeleteBehavior.Cascade);
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
index 778b888735..cb714a4014 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
@@ -93,28 +92,6 @@ namespace Jellyfin.Server.Implementations.Tests.Users
}
[Fact]
- public async Task UpdateUserAsync_DoesNotLeaveOrphanedPermissionsOrPreferences()
- {
- var user = await _userManager.CreateUserAsync("updateduser");
- var permissionCount = user.Permissions.Count;
- var preferenceCount = user.Preferences.Count;
-
- user.LastActivityDate = DateTime.UtcNow;
- await _userManager.UpdateUserAsync(user);
- await _userManager.UpdateUserAsync(user);
-
- await using var context = CreateDbContext();
- Assert.Empty(await context.Permissions
- .Where(permission => !permission.UserId.HasValue)
- .ToListAsync(TestContext.Current.CancellationToken));
- Assert.Empty(await context.Preferences
- .Where(preference => !preference.UserId.HasValue)
- .ToListAsync(TestContext.Current.CancellationToken));
- Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
- Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken));
- }
-
- [Fact]
public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage()
{
var user = await _userManager.CreateUserAsync("profileimageuser");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
new file mode 100644
index 0000000000..c940f92109
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+public sealed class UserManagerUpdateUserTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerUpdateUserTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ [defaultPasswordResetProvider],
+ [defaultAuthProvider, invalidAuthProvider]);
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences()
+ {
+ var user = await _userManager.CreateUserAsync("orphanuser");
+ var permissionCount = user.Permissions.Count;
+ var preferenceCount = user.Preferences.Count;
+
+ user.LastActivityDate = DateTime.UtcNow;
+ await _userManager.UpdateUserAsync(user);
+ await _userManager.UpdateUserAsync(user);
+
+ await using var context = CreateDbContext();
+ Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
+ Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken));
+ Assert.All(
+ await context.Permissions.ToListAsync(TestContext.Current.CancellationToken),
+ permission => Assert.Equal(user.Id, permission.UserId));
+ Assert.All(
+ await context.Preferences.ToListAsync(TestContext.Current.CancellationToken),
+ preference => Assert.Equal(user.Id, preference.UserId));
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched()
+ {
+ var user = await _userManager.CreateUserAsync("churnuser");
+ var before = await ReadChildRowsAsync();
+
+ // A session activity stamp goes through the same path. It must not rewrite all 37 child
+ // rows, which is what tearing the collections down and rebuilding them used to do.
+ user.LastActivityDate = DateTime.UtcNow;
+ await _userManager.UpdateUserAsync(user);
+
+ Assert.Equal(before, await ReadChildRowsAsync());
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges()
+ {
+ var user = await _userManager.CreateUserAsync("policyuser");
+ Assert.False(user.HasPermission(PermissionKind.IsAdministrator));
+
+ user.SetPermission(PermissionKind.IsAdministrator, true);
+ user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]);
+ user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels));
+
+ await _userManager.UpdateUserAsync(user);
+
+ var reloaded = _userManager.GetUserById(user.Id)!;
+ Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator));
+ Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags));
+ Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels);
+
+ await using var context = CreateDbContext();
+ Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ /// <summary>
+ /// Reads the identity and concurrency token of every permission and preference row.
+ /// </summary>
+ private async Task<List<(string Table, int Id, int Kind, uint RowVersion)>> ReadChildRowsAsync()
+ {
+ await using var context = CreateDbContext();
+ var permissions = await context.Permissions
+ .OrderBy(permission => permission.Id)
+ .Select(permission => new ValueTuple<string, int, int, uint>("Permission", permission.Id, (int)permission.Kind, permission.RowVersion))
+ .ToListAsync(TestContext.Current.CancellationToken);
+ var preferences = await context.Preferences
+ .OrderBy(preference => preference.Id)
+ .Select(preference => new ValueTuple<string, int, int, uint>("Preference", preference.Id, (int)preference.Kind, preference.RowVersion))
+ .ToListAsync(TestContext.Current.CancellationToken);
+
+ return permissions.Concat(preferences).ToList();
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+}