Browse Source

Add support for custom item display preferences

crobibero 4 years ago
parent
commit
b0c79edd2c

+ 34 - 2
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -81,6 +81,19 @@ namespace Jellyfin.Api.Controllers
             dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
 
+            // Load all custom display preferences
+            var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, displayPreferences.Client);
+            if (customDisplayPreferences != null)
+            {
+                foreach (var (key, value) in customDisplayPreferences)
+                {
+                    if (!dto.CustomPrefs.ContainsKey(key))
+                    {
+                        dto.CustomPrefs[key] = value;
+                    }
+                }
+            }
+
             // This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
             _displayPreferencesManager.SaveChanges();
 
@@ -124,21 +137,33 @@ namespace Jellyfin.Api.Controllers
             existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
                 ? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
                 : ChromecastVersion.Stable;
+            displayPreferences.CustomPrefs.Remove("chromecastVersion");
+
             existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
                 ? bool.Parse(enableNextVideoInfoOverlay)
                 : true;
+            displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
+
             existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
                 ? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
                 : 10000;
+            displayPreferences.CustomPrefs.Remove("skipBackLength");
+
             existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
                 ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
                 : 30000;
+            displayPreferences.CustomPrefs.Remove("skipForwardLength");
+
             existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
                 ? theme
                 : string.Empty;
+            displayPreferences.CustomPrefs.Remove("dashboardTheme");
+
             existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
                 ? home
                 : string.Empty;
+            displayPreferences.CustomPrefs.Remove("tvhome");
+
             existingDisplayPreferences.HomeSections.Clear();
 
             foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
@@ -149,13 +174,18 @@ namespace Jellyfin.Api.Controllers
                     type = order < 7 ? defaults[order] : HomeSectionType.None;
                 }
 
+                displayPreferences.CustomPrefs.Remove(key);
                 existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
             }
 
             foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
             {
-                var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client);
-                itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+                if (Guid.TryParse(key.Substring("landing-".Length), out var preferenceId))
+                {
+                    var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, preferenceId, existingDisplayPreferences.Client);
+                    itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+                    displayPreferences.CustomPrefs.Remove(key);
+                }
             }
 
             var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Empty, existingDisplayPreferences.Client);
@@ -169,6 +199,8 @@ namespace Jellyfin.Api.Controllers
                 itemPrefs.ViewType = viewType;
             }
 
+            // Set all remaining custom preferences.
+            _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
             _displayPreferencesManager.SaveChanges();
 
             return NoContent();

+ 80 - 0
Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs

@@ -0,0 +1,80 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Jellyfin.Data.Entities
+{
+    /// <summary>
+    /// An entity that represents a user's custom display preferences for a specific item.
+    /// </summary>
+    public class CustomItemDisplayPreferences
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="CustomItemDisplayPreferences"/> class.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="client">The client.</param>
+        /// <param name="preferenceKey">The preference key.</param>
+        /// <param name="preferenceValue">The preference value.</param>
+        public CustomItemDisplayPreferences(Guid userId, string client, string preferenceKey, string preferenceValue)
+        {
+            UserId = userId;
+            Client = client;
+            Key = preferenceKey;
+            Value = preferenceValue;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="CustomItemDisplayPreferences"/> class.
+        /// </summary>
+        protected CustomItemDisplayPreferences()
+        {
+        }
+
+        /// <summary>
+        /// Gets or sets the Id.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+        public int Id { get; protected set; }
+
+        /// <summary>
+        /// Gets or sets the user Id.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public Guid UserId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the client string.
+        /// </summary>
+        /// <remarks>
+        /// Required. Max Length = 32.
+        /// </remarks>
+        [Required]
+        [MaxLength(32)]
+        [StringLength(32)]
+        public string Client { get; set; }
+
+        /// <summary>
+        /// Gets or sets the preference key.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public string Key { get; set; }
+
+        /// <summary>
+        /// Gets or sets the preference value.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public string Value { get; set; }
+    }
+}

+ 10 - 0
Jellyfin.Server.Implementations/JellyfinDb.cs

@@ -34,6 +34,8 @@ namespace Jellyfin.Server.Implementations
 
         public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; }
 
+        public virtual DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences { get; set; }
+
         public virtual DbSet<Permission> Permissions { get; set; }
 
         public virtual DbSet<Preference> Preferences { get; set; }
@@ -153,6 +155,14 @@ namespace Jellyfin.Server.Implementations
             modelBuilder.Entity<DisplayPreferences>()
                 .HasIndex(entity => new { entity.UserId, entity.Client })
                 .IsUnique();
+
+            modelBuilder.Entity<CustomItemDisplayPreferences>()
+                .HasIndex(entity => entity.UserId)
+                .IsUnique(false);
+
+            modelBuilder.Entity<CustomItemDisplayPreferences>()
+                .HasIndex(entity => new { entity.UserId, entity.Client, entity.Key })
+                .IsUnique();
         }
     }
 }

+ 516 - 0
Jellyfin.Server.Implementations/Migrations/20201203203707_AddCustomDisplayPreferences.Designer.cs

@@ -0,0 +1,516 @@
+#pragma warning disable CS1591
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    [DbContext(typeof(JellyfinDb))]
+    [Migration("20201203203707_AddCustomDisplayPreferences")]
+    partial class AddCustomDisplayPreferences
+    {
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder
+                .HasDefaultSchema("jellyfin")
+                .HasAnnotation("ProductVersion", "5.0.0");
+
+            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.ToTable("ActivityLogs");
+                });
+
+            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<string>("Key")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.HasIndex("UserId", "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<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");
+
+                    b.HasIndex("UserId", "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.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<bool>("Value")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Permission_Permissions_Guid");
+
+                    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<string>("Value")
+                        .IsRequired()
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Preference_Preferences_Guid");
+
+                    b.ToTable("Preferences");
+                });
+
+            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<bool>("DisplayCollectionsView")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("DisplayMissingEpisodes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("EasyPassword")
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    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");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("Users");
+                });
+
+            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.DisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithOne("DisplayPreferences")
+                        .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "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");
+                });
+
+            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.Permission", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Permissions")
+                        .HasForeignKey("Permission_Permissions_Guid");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Preferences")
+                        .HasForeignKey("Preference_Preferences_Guid");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Navigation("HomeSections");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Navigation("AccessSchedules");
+
+                    b.Navigation("DisplayPreferences")
+                        .IsRequired();
+
+                    b.Navigation("ItemDisplayPreferences");
+
+                    b.Navigation("Permissions");
+
+                    b.Navigation("Preferences");
+
+                    b.Navigation("ProfileImage");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}

+ 70 - 0
Jellyfin.Server.Implementations/Migrations/20201203203707_AddCustomDisplayPreferences.cs

@@ -0,0 +1,70 @@
+#pragma warning disable CS1591
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    public partial class AddCustomDisplayPreferences : Migration
+    {
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.AlterColumn<int>(
+                name: "MaxActiveSessions",
+                schema: "jellyfin",
+                table: "Users",
+                type: "INTEGER",
+                nullable: false,
+                defaultValue: 0,
+                oldClrType: typeof(int),
+                oldType: "INTEGER",
+                oldNullable: true);
+
+            migrationBuilder.CreateTable(
+                name: "CustomItemDisplayPreferences",
+                schema: "jellyfin",
+                columns: table => new
+                {
+                    Id = table.Column<int>(type: "INTEGER", nullable: false)
+                        .Annotation("Sqlite:Autoincrement", true),
+                    UserId = table.Column<Guid>(type: "TEXT", nullable: false),
+                    Client = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
+                    Key = table.Column<string>(type: "TEXT", nullable: false),
+                    Value = table.Column<string>(type: "TEXT", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_CustomItemDisplayPreferences", x => x.Id);
+                });
+
+            migrationBuilder.CreateIndex(
+                name: "IX_CustomItemDisplayPreferences_UserId",
+                schema: "jellyfin",
+                table: "CustomItemDisplayPreferences",
+                column: "UserId");
+
+            migrationBuilder.CreateIndex(
+                name: "IX_CustomItemDisplayPreferences_UserId_Client_Key",
+                schema: "jellyfin",
+                table: "CustomItemDisplayPreferences",
+                columns: new[] { "UserId", "Client", "Key" },
+                unique: true);
+        }
+
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropTable(
+                name: "CustomItemDisplayPreferences",
+                schema: "jellyfin");
+
+            migrationBuilder.AlterColumn<int>(
+                name: "MaxActiveSessions",
+                schema: "jellyfin",
+                table: "Users",
+                type: "INTEGER",
+                nullable: true,
+                oldClrType: typeof(int),
+                oldType: "INTEGER");
+        }
+    }
+}

+ 92 - 39
Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
 #pragma warning disable 612, 618
             modelBuilder
                 .HasDefaultSchema("jellyfin")
-                .HasAnnotation("ProductVersion", "3.1.8");
+                .HasAnnotation("ProductVersion", "5.0.0");
 
             modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
                 {
@@ -52,33 +52,33 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .HasColumnType("TEXT");
 
                     b.Property<string>("ItemId")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(256);
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
 
                     b.Property<int>("LogSeverity")
                         .HasColumnType("INTEGER");
 
                     b.Property<string>("Name")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(512);
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
 
                     b.Property<string>("Overview")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(512);
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
 
                     b.Property<uint>("RowVersion")
                         .IsConcurrencyToken()
                         .HasColumnType("INTEGER");
 
                     b.Property<string>("ShortOverview")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(512);
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
 
                     b.Property<string>("Type")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(256);
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
 
                     b.Property<Guid>("UserId")
                         .HasColumnType("TEXT");
@@ -88,6 +88,38 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.ToTable("ActivityLogs");
                 });
 
+            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<string>("Key")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.HasIndex("UserId", "Client", "Key")
+                        .IsUnique();
+
+                    b.ToTable("CustomItemDisplayPreferences");
+                });
+
             modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
                 {
                     b.Property<int>("Id")
@@ -99,12 +131,12 @@ namespace Jellyfin.Server.Implementations.Migrations
 
                     b.Property<string>("Client")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(32);
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
 
                     b.Property<string>("DashboardTheme")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(32);
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
 
                     b.Property<bool>("EnableNextVideoInfoOverlay")
                         .HasColumnType("INTEGER");
@@ -128,8 +160,8 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .HasColumnType("INTEGER");
 
                     b.Property<string>("TvHome")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(32);
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
 
                     b.Property<Guid>("UserId")
                         .HasColumnType("TEXT");
@@ -177,8 +209,8 @@ namespace Jellyfin.Server.Implementations.Migrations
 
                     b.Property<string>("Path")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(512);
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
 
                     b.Property<Guid?>("UserId")
                         .HasColumnType("TEXT");
@@ -199,8 +231,8 @@ namespace Jellyfin.Server.Implementations.Migrations
 
                     b.Property<string>("Client")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(32);
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
 
                     b.Property<int?>("IndexBy")
                         .HasColumnType("INTEGER");
@@ -216,8 +248,8 @@ namespace Jellyfin.Server.Implementations.Migrations
 
                     b.Property<string>("SortBy")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(64);
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
 
                     b.Property<int>("SortOrder")
                         .HasColumnType("INTEGER");
@@ -279,8 +311,8 @@ namespace Jellyfin.Server.Implementations.Migrations
 
                     b.Property<string>("Value")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(65535);
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
 
                     b.HasKey("Id");
 
@@ -296,13 +328,13 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .HasColumnType("TEXT");
 
                     b.Property<string>("AudioLanguagePreference")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(255);
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
 
                     b.Property<string>("AuthenticationProviderId")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(255);
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
 
                     b.Property<bool>("DisplayCollectionsView")
                         .HasColumnType("INTEGER");
@@ -311,8 +343,8 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .HasColumnType("INTEGER");
 
                     b.Property<string>("EasyPassword")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(65535);
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
 
                     b.Property<bool>("EnableAutoLogin")
                         .HasColumnType("INTEGER");
@@ -354,13 +386,13 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .HasColumnType("INTEGER");
 
                     b.Property<string>("Password")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(65535);
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
 
                     b.Property<string>("PasswordResetProviderId")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(255);
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
 
                     b.Property<bool>("PlayDefaultAudioTrack")
                         .HasColumnType("INTEGER");
@@ -379,8 +411,8 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .HasColumnType("INTEGER");
 
                     b.Property<string>("SubtitleLanguagePreference")
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(255);
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
 
                     b.Property<int>("SubtitleMode")
                         .HasColumnType("INTEGER");
@@ -390,8 +422,8 @@ namespace Jellyfin.Server.Implementations.Migrations
 
                     b.Property<string>("Username")
                         .IsRequired()
-                        .HasColumnType("TEXT")
-                        .HasMaxLength(255);
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
 
                     b.HasKey("Id");
 
@@ -454,6 +486,27 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .WithMany("Preferences")
                         .HasForeignKey("Preference_Preferences_Guid");
                 });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Navigation("HomeSections");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Navigation("AccessSchedules");
+
+                    b.Navigation("DisplayPreferences")
+                        .IsRequired();
+
+                    b.Navigation("ItemDisplayPreferences");
+
+                    b.Navigation("Permissions");
+
+                    b.Navigation("Preferences");
+
+                    b.Navigation("ProfileImage");
+                });
 #pragma warning restore 612, 618
         }
     }

+ 24 - 0
Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs

@@ -66,6 +66,30 @@ namespace Jellyfin.Server.Implementations.Users
                 .ToList();
         }
 
+        /// <inheritdoc />
+        public IDictionary<string, string> ListCustomItemDisplayPreferences(Guid userId, string client)
+        {
+            return _dbContext.CustomItemDisplayPreferences
+                .AsQueryable()
+                .Where(prefs => prefs.UserId == userId && string.Equals(prefs.Client, client))
+                .ToDictionary(prefs => prefs.Key, prefs => prefs.Value);
+        }
+
+        /// <inheritdoc />
+        public void SetCustomItemDisplayPreferences(Guid userId, string client, Dictionary<string, string> customPreferences)
+        {
+            var existingPrefs = _dbContext.CustomItemDisplayPreferences
+                .AsQueryable()
+                .Where(prefs => prefs.UserId == userId && string.Equals(prefs.Client, client));
+            _dbContext.CustomItemDisplayPreferences.RemoveRange(existingPrefs);
+
+            foreach (var (key, value) in customPreferences)
+            {
+                _dbContext.CustomItemDisplayPreferences
+                    .Add(new CustomItemDisplayPreferences(userId, client, key, value));
+            }
+        }
+
         /// <inheritdoc />
         public void SaveChanges()
         {

+ 16 - 0
MediaBrowser.Controller/IDisplayPreferencesManager.cs

@@ -40,6 +40,22 @@ namespace MediaBrowser.Controller
         /// <returns>A list of item display preferences.</returns>
         IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client);
 
+        /// <summary>
+        /// Gets all of the custom item display preferences for the user and client.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="client">The client string.</param>
+        /// <returns>The dictionary of custom item display preferences.</returns>
+        IDictionary<string, string> ListCustomItemDisplayPreferences(Guid userId, string client);
+
+        /// <summary>
+        /// Sets the custom item display preference for the user and client.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="client">The client id.</param>
+        /// <param name="customPreferences">A dictionary of custom item display preferences.</param>
+        void SetCustomItemDisplayPreferences(Guid userId, string client, Dictionary<string, string> customPreferences);
+
         /// <summary>
         /// Saves changes made to the database.
         /// </summary>