瀏覽代碼

Merge remote-tracking branch 'upstream/master' into api-migration

crobibero 4 年之前
父節點
當前提交
f915c3e5d9
共有 100 個文件被更改,包括 2926 次插入1523 次删除
  1. 9 1
      .ci/azure-pipelines-package.yml
  2. 1 0
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  3. 2 1
      Emby.Dlna/Didl/DidlBuilder.cs
  4. 12 12
      Emby.Dlna/DlnaManager.cs
  5. 9 5
      Emby.Dlna/Eventing/EventManager.cs
  6. 2 2
      Emby.Dlna/PlayTo/Device.cs
  7. 75 107
      Emby.Dlna/Server/DescriptionXmlBuilder.cs
  8. 25 9
      Emby.Dlna/Service/ServiceXmlBuilder.cs
  9. 7 7
      Emby.Drawing/ImageProcessor.cs
  10. 30 30
      Emby.Naming/Common/NamingOptions.cs
  11. 1 1
      Emby.Naming/TV/SeasonPathParser.cs
  12. 2 6
      Emby.Server.Implementations/ApplicationHost.cs
  13. 10 14
      Emby.Server.Implementations/Channels/ChannelManager.cs
  14. 0 225
      Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs
  15. 54 139
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  16. 12 11
      Emby.Server.Implementations/Devices/DeviceManager.cs
  17. 4 4
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  18. 0 2
      Emby.Server.Implementations/HttpServer/FileWriter.cs
  19. 2 2
      Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
  20. 0 4
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  21. 2 1
      Emby.Server.Implementations/IO/FileRefresher.cs
  22. 2 8
      Emby.Server.Implementations/Images/ArtistImageProvider.cs
  23. 1 1
      Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
  24. 1 1
      Emby.Server.Implementations/Images/FolderImageProvider.cs
  25. 1 0
      Emby.Server.Implementations/Images/GenreImageProvider.cs
  26. 1 1
      Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
  27. 12 12
      Emby.Server.Implementations/Library/ExclusiveLiveStream.cs
  28. 20 3
      Emby.Server.Implementations/Library/IgnorePatterns.cs
  29. 114 124
      Emby.Server.Implementations/Library/LibraryManager.cs
  30. 12 12
      Emby.Server.Implementations/Library/LiveStreamHelper.cs
  31. 23 25
      Emby.Server.Implementations/Library/MediaSourceManager.cs
  32. 1 1
      Emby.Server.Implementations/Library/MediaStreamSelector.cs
  33. 1 1
      Emby.Server.Implementations/Library/MusicManager.cs
  34. 0 5
      Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
  35. 7 13
      Emby.Server.Implementations/Library/SearchEngine.cs
  36. 0 4
      Emby.Server.Implementations/Library/UserDataManager.cs
  37. 1 0
      Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
  38. 2 2
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  39. 3 6
      Emby.Server.Implementations/LiveTv/LiveTvManager.cs
  40. 7 2
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
  41. 6 5
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
  42. 12 13
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  43. 2 2
      Emby.Server.Implementations/Localization/Core/de.json
  44. 1 1
      Emby.Server.Implementations/Localization/Core/zh-TW.json
  45. 4 4
      Emby.Server.Implementations/Localization/LocalizationManager.cs
  46. 23 20
      Emby.Server.Implementations/Net/UdpSocket.cs
  47. 2 2
      Emby.Server.Implementations/Networking/NetworkManager.cs
  48. 20 44
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  49. 1 6
      Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
  50. 0 8
      Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
  51. 14 5
      Emby.Server.Implementations/Services/ServiceController.cs
  52. 8 1
      Emby.Server.Implementations/Services/ServiceExec.cs
  53. 16 10
      Emby.Server.Implementations/Services/ServiceHandler.cs
  54. 5 3
      Emby.Server.Implementations/Services/ServicePath.cs
  55. 2 2
      Emby.Server.Implementations/Session/SessionManager.cs
  56. 16 14
      Emby.Server.Implementations/Session/SessionWebSocketListener.cs
  57. 2 2
      Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs
  58. 32 28
      Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
  59. 3 5
      Jellyfin.Api/Controllers/StartupController.cs
  60. 150 0
      Jellyfin.Data/Entities/DisplayPreferences.cs
  61. 46 0
      Jellyfin.Data/Entities/HomeSection.cs
  62. 120 0
      Jellyfin.Data/Entities/ItemDisplayPreferences.cs
  63. 15 0
      Jellyfin.Data/Entities/User.cs
  64. 18 0
      Jellyfin.Data/Enums/ChromecastVersion.cs
  65. 53 0
      Jellyfin.Data/Enums/HomeSectionType.cs
  66. 20 0
      Jellyfin.Data/Enums/IndexingKind.cs
  67. 18 0
      Jellyfin.Data/Enums/ScrollDirection.cs
  68. 18 0
      Jellyfin.Data/Enums/SortOrder.cs
  69. 38 0
      Jellyfin.Data/Enums/ViewType.cs
  70. 4 5
      Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
  71. 9 13
      Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs
  72. 15 19
      Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs
  73. 1 1
      Jellyfin.Drawing.Skia/SkiaCodecException.cs
  74. 214 311
      Jellyfin.Drawing.Skia/SkiaEncoder.cs
  75. 1 1
      Jellyfin.Drawing.Skia/SkiaException.cs
  76. 49 69
      Jellyfin.Drawing.Skia/StripCollageBuilder.cs
  77. 24 28
      Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
  78. 17 0
      Jellyfin.Server.Implementations/JellyfinDb.cs
  79. 11 3
      Jellyfin.Server.Implementations/JellyfinDbProvider.cs
  80. 459 0
      Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs
  81. 132 0
      Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs
  82. 148 1
      Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
  83. 12 16
      Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
  84. 88 0
      Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
  85. 60 48
      Jellyfin.Server.Implementations/Users/UserManager.cs
  86. 10 7
      Jellyfin.Server/CoreAppHost.cs
  87. 1 1
      Jellyfin.Server/Jellyfin.Server.csproj
  88. 3 1
      Jellyfin.Server/Migrations/MigrationRunner.cs
  89. 174 0
      Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
  90. 49 0
      Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
  91. 15 0
      Jellyfin.Server/Program.cs
  92. 189 0
      MediaBrowser.Api/DisplayPreferencesService.cs
  93. 2 1
      MediaBrowser.Api/Playback/Hls/BaseHlsService.cs
  94. 10 6
      MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs
  95. 33 0
      MediaBrowser.Common/Extensions/HttpContextExtensions.cs
  96. 9 7
      MediaBrowser.Controller/Entities/BaseItem.cs
  97. 1 0
      MediaBrowser.Controller/Entities/UserViewBuilder.cs
  98. 49 0
      MediaBrowser.Controller/IDisplayPreferencesManager.cs
  99. 2 1
      MediaBrowser.Controller/Library/ILibraryManager.cs
  100. 2 5
      MediaBrowser.Controller/Library/IUserManager.cs

+ 9 - 1
.ci/azure-pipelines-package.yml

@@ -80,7 +80,15 @@ jobs:
   pool:
     vmImage: 'ubuntu-latest'
 
+  variables:
+  - name: JellyfinVersion
+    value: 0.0.0
+
   steps:
+  - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
+    displayName: Set release version (stable)
+    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
+
   - task: Docker@2
     displayName: 'Push Unstable Image'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
@@ -105,7 +113,7 @@ jobs:
       containerRegistry: Docker Hub
       tags: |
         stable-$(Build.BuildNumber)-$(BuildConfiguration)
-        stable-$(BuildConfiguration)
+        $(JellyfinVersion)-$(BuildConfiguration)
 
 - job: CollectArtifacts
   displayName: 'Collect Artifacts'

+ 1 - 0
Emby.Dlna/ContentDirectory/ControlHandler.cs

@@ -11,6 +11,7 @@ using System.Xml;
 using Emby.Dlna.Didl;
 using Emby.Dlna.Service;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Drawing;

+ 2 - 1
Emby.Dlna/Didl/DidlBuilder.cs

@@ -364,7 +364,8 @@ namespace Emby.Dlna.Didl
                 writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(_usCulture));
             }
 
-            var mediaProfile = _profile.GetVideoMediaProfile(streamInfo.Container,
+            var mediaProfile = _profile.GetVideoMediaProfile(
+                streamInfo.Container,
                 streamInfo.TargetAudioCodec.FirstOrDefault(),
                 streamInfo.TargetVideoCodec.FirstOrDefault(),
                 streamInfo.TargetAudioBitrate,

+ 12 - 12
Emby.Dlna/DlnaManager.cs

@@ -122,15 +122,15 @@ namespace Emby.Dlna
             var builder = new StringBuilder();
 
             builder.AppendLine("No matching device profile found. The default will need to be used.");
-            builder.AppendLine(string.Format("DeviceDescription:{0}", profile.DeviceDescription ?? string.Empty));
-            builder.AppendLine(string.Format("FriendlyName:{0}", profile.FriendlyName ?? string.Empty));
-            builder.AppendLine(string.Format("Manufacturer:{0}", profile.Manufacturer ?? string.Empty));
-            builder.AppendLine(string.Format("ManufacturerUrl:{0}", profile.ManufacturerUrl ?? string.Empty));
-            builder.AppendLine(string.Format("ModelDescription:{0}", profile.ModelDescription ?? string.Empty));
-            builder.AppendLine(string.Format("ModelName:{0}", profile.ModelName ?? string.Empty));
-            builder.AppendLine(string.Format("ModelNumber:{0}", profile.ModelNumber ?? string.Empty));
-            builder.AppendLine(string.Format("ModelUrl:{0}", profile.ModelUrl ?? string.Empty));
-            builder.AppendLine(string.Format("SerialNumber:{0}", profile.SerialNumber ?? string.Empty));
+            builder.AppendFormat(CultureInfo.InvariantCulture, "DeviceDescription:{0}", profile.DeviceDescription ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "FriendlyName:{0}", profile.FriendlyName ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "Manufacturer:{0}", profile.Manufacturer ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ManufacturerUrl:{0}", profile.ManufacturerUrl ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelDescription:{0}", profile.ModelDescription ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelName:{0}", profile.ModelName ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelNumber:{0}", profile.ModelNumber ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "ModelUrl:{0}", profile.ModelUrl ?? string.Empty).AppendLine();
+            builder.AppendFormat(CultureInfo.InvariantCulture, "SerialNumber:{0}", profile.SerialNumber ?? string.Empty).AppendLine();
 
             _logger.LogInformation(builder.ToString());
         }
@@ -387,7 +387,7 @@ namespace Emby.Dlna
 
             foreach (var name in _assembly.GetManifestResourceNames())
             {
-                if (!name.StartsWith(namespaceName))
+                if (!name.StartsWith(namespaceName, StringComparison.Ordinal))
                 {
                     continue;
                 }
@@ -406,7 +406,7 @@ namespace Emby.Dlna
 
                         using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
                         {
-                            await stream.CopyToAsync(fileStream);
+                            await stream.CopyToAsync(fileStream).ConfigureAwait(false);
                         }
                     }
                 }
@@ -509,7 +509,7 @@ namespace Emby.Dlna
             return _jsonSerializer.DeserializeFromString<DeviceProfile>(json);
         }
 
-        class InternalProfileInfo
+        private class InternalProfileInfo
         {
             internal DeviceProfileInfo Info { get; set; }
 

+ 9 - 5
Emby.Dlna/Eventing/EventManager.cs

@@ -152,11 +152,15 @@ namespace Emby.Dlna.Eventing
             builder.Append("<e:propertyset xmlns:e=\"urn:schemas-upnp-org:event-1-0\">");
             foreach (var key in stateVariables.Keys)
             {
-                builder.Append("<e:property>");
-                builder.Append("<" + key + ">");
-                builder.Append(stateVariables[key]);
-                builder.Append("</" + key + ">");
-                builder.Append("</e:property>");
+                builder.Append("<e:property>")
+                    .Append('<')
+                    .Append(key)
+                    .Append('>')
+                    .Append(stateVariables[key])
+                    .Append("</")
+                    .Append(key)
+                    .Append('>')
+                    .Append("</e:property>");
             }
 
             builder.Append("</e:propertyset>");

+ 2 - 2
Emby.Dlna/PlayTo/Device.cs

@@ -4,12 +4,12 @@ using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
+using System.Security;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Xml;
 using System.Xml.Linq;
 using Emby.Dlna.Common;
-using Emby.Dlna.Server;
 using Emby.Dlna.Ssdp;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Configuration;
@@ -334,7 +334,7 @@ namespace Emby.Dlna.PlayTo
                 return string.Empty;
             }
 
-            return DescriptionXmlBuilder.Escape(value);
+            return SecurityElement.Escape(value);
         }
 
         private Task SetPlay(TransportCommands avCommands, CancellationToken cancellationToken)

+ 75 - 107
Emby.Dlna/Server/DescriptionXmlBuilder.cs

@@ -4,6 +4,7 @@ using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
+using System.Security;
 using System.Text;
 using Emby.Dlna.Common;
 using MediaBrowser.Model.Dlna;
@@ -64,10 +65,10 @@ namespace Emby.Dlna.Server
 
             foreach (var att in attributes)
             {
-                builder.AppendFormat(" {0}=\"{1}\"", att.Name, att.Value);
+                builder.AppendFormat(CultureInfo.InvariantCulture, " {0}=\"{1}\"", att.Name, att.Value);
             }
 
-            builder.Append(">");
+            builder.Append('>');
 
             builder.Append("<specVersion>");
             builder.Append("<major>1</major>");
@@ -76,7 +77,9 @@ namespace Emby.Dlna.Server
 
             if (!EnableAbsoluteUrls)
             {
-                builder.Append("<URLBase>" + Escape(_serverAddress) + "</URLBase>");
+                builder.Append("<URLBase>")
+                    .Append(SecurityElement.Escape(_serverAddress))
+                    .Append("</URLBase>");
             }
 
             AppendDeviceInfo(builder);
@@ -93,91 +96,14 @@ namespace Emby.Dlna.Server
 
             AppendIconList(builder);
 
-            builder.Append("<presentationURL>" + Escape(_serverAddress) + "/web/index.html</presentationURL>");
+            builder.Append("<presentationURL>")
+                .Append(SecurityElement.Escape(_serverAddress))
+                .Append("/web/index.html</presentationURL>");
 
             AppendServiceList(builder);
             builder.Append("</device>");
         }
 
-        private static readonly char[] s_escapeChars = new char[]
-        {
-            '<',
-            '>',
-            '"',
-            '\'',
-            '&'
-        };
-
-        private static readonly string[] s_escapeStringPairs = new[]
-        {
-            "<",
-            "&lt;",
-            ">",
-            "&gt;",
-            "\"",
-            "&quot;",
-            "'",
-            "&apos;",
-            "&",
-            "&amp;"
-        };
-
-        private static string GetEscapeSequence(char c)
-        {
-            int num = s_escapeStringPairs.Length;
-            for (int i = 0; i < num; i += 2)
-            {
-                string text = s_escapeStringPairs[i];
-                string result = s_escapeStringPairs[i + 1];
-                if (text[0] == c)
-                {
-                    return result;
-                }
-            }
-
-            return c.ToString(CultureInfo.InvariantCulture);
-        }
-
-        /// <summary>Replaces invalid XML characters in a string with their valid XML equivalent.</summary>
-        /// <returns>The input string with invalid characters replaced.</returns>
-        /// <param name="str">The string within which to escape invalid characters. </param>
-        public static string Escape(string str)
-        {
-            if (str == null)
-            {
-                return null;
-            }
-
-            StringBuilder stringBuilder = null;
-            int length = str.Length;
-            int num = 0;
-            while (true)
-            {
-                int num2 = str.IndexOfAny(s_escapeChars, num);
-                if (num2 == -1)
-                {
-                    break;
-                }
-
-                if (stringBuilder == null)
-                {
-                    stringBuilder = new StringBuilder();
-                }
-
-                stringBuilder.Append(str, num, num2 - num);
-                stringBuilder.Append(GetEscapeSequence(str[num2]));
-                num = num2 + 1;
-            }
-
-            if (stringBuilder == null)
-            {
-                return str;
-            }
-
-            stringBuilder.Append(str, num, length - num);
-            return stringBuilder.ToString();
-        }
-
         private void AppendDeviceProperties(StringBuilder builder)
         {
             builder.Append("<dlna:X_DLNACAP/>");
@@ -187,32 +113,54 @@ namespace Emby.Dlna.Server
 
             builder.Append("<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>");
 
-            builder.Append("<friendlyName>" + Escape(GetFriendlyName()) + "</friendlyName>");
-            builder.Append("<manufacturer>" + Escape(_profile.Manufacturer ?? string.Empty) + "</manufacturer>");
-            builder.Append("<manufacturerURL>" + Escape(_profile.ManufacturerUrl ?? string.Empty) + "</manufacturerURL>");
-
-            builder.Append("<modelDescription>" + Escape(_profile.ModelDescription ?? string.Empty) + "</modelDescription>");
-            builder.Append("<modelName>" + Escape(_profile.ModelName ?? string.Empty) + "</modelName>");
-
-            builder.Append("<modelNumber>" + Escape(_profile.ModelNumber ?? string.Empty) + "</modelNumber>");
-            builder.Append("<modelURL>" + Escape(_profile.ModelUrl ?? string.Empty) + "</modelURL>");
+            builder.Append("<friendlyName>")
+                .Append(SecurityElement.Escape(GetFriendlyName()))
+                .Append("</friendlyName>");
+            builder.Append("<manufacturer>")
+                .Append(SecurityElement.Escape(_profile.Manufacturer ?? string.Empty))
+                .Append("</manufacturer>");
+            builder.Append("<manufacturerURL>")
+                .Append(SecurityElement.Escape(_profile.ManufacturerUrl ?? string.Empty))
+                .Append("</manufacturerURL>");
+
+            builder.Append("<modelDescription>")
+                .Append(SecurityElement.Escape(_profile.ModelDescription ?? string.Empty))
+                .Append("</modelDescription>");
+            builder.Append("<modelName>")
+                .Append(SecurityElement.Escape(_profile.ModelName ?? string.Empty))
+                .Append("</modelName>");
+
+            builder.Append("<modelNumber>")
+                .Append(SecurityElement.Escape(_profile.ModelNumber ?? string.Empty))
+                .Append("</modelNumber>");
+            builder.Append("<modelURL>")
+                .Append(SecurityElement.Escape(_profile.ModelUrl ?? string.Empty))
+                .Append("</modelURL>");
 
             if (string.IsNullOrEmpty(_profile.SerialNumber))
             {
-                builder.Append("<serialNumber>" + Escape(_serverId) + "</serialNumber>");
+                builder.Append("<serialNumber>")
+                    .Append(SecurityElement.Escape(_serverId))
+                    .Append("</serialNumber>");
             }
             else
             {
-                builder.Append("<serialNumber>" + Escape(_profile.SerialNumber) + "</serialNumber>");
+                builder.Append("<serialNumber>")
+                    .Append(SecurityElement.Escape(_profile.SerialNumber))
+                    .Append("</serialNumber>");
             }
 
             builder.Append("<UPC/>");
 
-            builder.Append("<UDN>uuid:" + Escape(_serverUdn) + "</UDN>");
+            builder.Append("<UDN>uuid:")
+                .Append(SecurityElement.Escape(_serverUdn))
+                .Append("</UDN>");
 
             if (!string.IsNullOrEmpty(_profile.SonyAggregationFlags))
             {
-                builder.Append("<av:aggregationFlags xmlns:av=\"urn:schemas-sony-com:av\">" + Escape(_profile.SonyAggregationFlags) + "</av:aggregationFlags>");
+                builder.Append("<av:aggregationFlags xmlns:av=\"urn:schemas-sony-com:av\">")
+                    .Append(SecurityElement.Escape(_profile.SonyAggregationFlags))
+                    .Append("</av:aggregationFlags>");
             }
         }
 
@@ -250,11 +198,21 @@ namespace Emby.Dlna.Server
             {
                 builder.Append("<icon>");
 
-                builder.Append("<mimetype>" + Escape(icon.MimeType ?? string.Empty) + "</mimetype>");
-                builder.Append("<width>" + Escape(icon.Width.ToString(_usCulture)) + "</width>");
-                builder.Append("<height>" + Escape(icon.Height.ToString(_usCulture)) + "</height>");
-                builder.Append("<depth>" + Escape(icon.Depth ?? string.Empty) + "</depth>");
-                builder.Append("<url>" + BuildUrl(icon.Url) + "</url>");
+                builder.Append("<mimetype>")
+                    .Append(SecurityElement.Escape(icon.MimeType ?? string.Empty))
+                    .Append("</mimetype>");
+                builder.Append("<width>")
+                    .Append(SecurityElement.Escape(icon.Width.ToString(_usCulture)))
+                    .Append("</width>");
+                builder.Append("<height>")
+                    .Append(SecurityElement.Escape(icon.Height.ToString(_usCulture)))
+                    .Append("</height>");
+                builder.Append("<depth>")
+                    .Append(SecurityElement.Escape(icon.Depth ?? string.Empty))
+                    .Append("</depth>");
+                builder.Append("<url>")
+                    .Append(BuildUrl(icon.Url))
+                    .Append("</url>");
 
                 builder.Append("</icon>");
             }
@@ -270,11 +228,21 @@ namespace Emby.Dlna.Server
             {
                 builder.Append("<service>");
 
-                builder.Append("<serviceType>" + Escape(service.ServiceType ?? string.Empty) + "</serviceType>");
-                builder.Append("<serviceId>" + Escape(service.ServiceId ?? string.Empty) + "</serviceId>");
-                builder.Append("<SCPDURL>" + BuildUrl(service.ScpdUrl) + "</SCPDURL>");
-                builder.Append("<controlURL>" + BuildUrl(service.ControlUrl) + "</controlURL>");
-                builder.Append("<eventSubURL>" + BuildUrl(service.EventSubUrl) + "</eventSubURL>");
+                builder.Append("<serviceType>")
+                    .Append(SecurityElement.Escape(service.ServiceType ?? string.Empty))
+                    .Append("</serviceType>");
+                builder.Append("<serviceId>")
+                    .Append(SecurityElement.Escape(service.ServiceId ?? string.Empty))
+                    .Append("</serviceId>");
+                builder.Append("<SCPDURL>")
+                    .Append(BuildUrl(service.ScpdUrl))
+                    .Append("</SCPDURL>");
+                builder.Append("<controlURL>")
+                    .Append(BuildUrl(service.ControlUrl))
+                    .Append("</controlURL>");
+                builder.Append("<eventSubURL>")
+                    .Append(BuildUrl(service.EventSubUrl))
+                    .Append("</eventSubURL>");
 
                 builder.Append("</service>");
             }
@@ -298,7 +266,7 @@ namespace Emby.Dlna.Server
                 url = _serverAddress.TrimEnd('/') + url;
             }
 
-            return Escape(url);
+            return SecurityElement.Escape(url);
         }
 
         private IEnumerable<DeviceIcon> GetIcons()

+ 25 - 9
Emby.Dlna/Service/ServiceXmlBuilder.cs

@@ -1,9 +1,9 @@
 #pragma warning disable CS1591
 
 using System.Collections.Generic;
+using System.Security;
 using System.Text;
 using Emby.Dlna.Common;
-using Emby.Dlna.Server;
 
 namespace Emby.Dlna.Service
 {
@@ -37,7 +37,9 @@ namespace Emby.Dlna.Service
             {
                 builder.Append("<action>");
 
-                builder.Append("<name>" + DescriptionXmlBuilder.Escape(item.Name ?? string.Empty) + "</name>");
+                builder.Append("<name>")
+                    .Append(SecurityElement.Escape(item.Name ?? string.Empty))
+                    .Append("</name>");
 
                 builder.Append("<argumentList>");
 
@@ -45,9 +47,15 @@ namespace Emby.Dlna.Service
                 {
                     builder.Append("<argument>");
 
-                    builder.Append("<name>" + DescriptionXmlBuilder.Escape(argument.Name ?? string.Empty) + "</name>");
-                    builder.Append("<direction>" + DescriptionXmlBuilder.Escape(argument.Direction ?? string.Empty) + "</direction>");
-                    builder.Append("<relatedStateVariable>" + DescriptionXmlBuilder.Escape(argument.RelatedStateVariable ?? string.Empty) + "</relatedStateVariable>");
+                    builder.Append("<name>")
+                        .Append(SecurityElement.Escape(argument.Name ?? string.Empty))
+                        .Append("</name>");
+                    builder.Append("<direction>")
+                        .Append(SecurityElement.Escape(argument.Direction ?? string.Empty))
+                        .Append("</direction>");
+                    builder.Append("<relatedStateVariable>")
+                        .Append(SecurityElement.Escape(argument.RelatedStateVariable ?? string.Empty))
+                        .Append("</relatedStateVariable>");
 
                     builder.Append("</argument>");
                 }
@@ -68,17 +76,25 @@ namespace Emby.Dlna.Service
             {
                 var sendEvents = item.SendsEvents ? "yes" : "no";
 
-                builder.Append("<stateVariable sendEvents=\"" + sendEvents + "\">");
+                builder.Append("<stateVariable sendEvents=\"")
+                    .Append(sendEvents)
+                    .Append("\">");
 
-                builder.Append("<name>" + DescriptionXmlBuilder.Escape(item.Name ?? string.Empty) + "</name>");
-                builder.Append("<dataType>" + DescriptionXmlBuilder.Escape(item.DataType ?? string.Empty) + "</dataType>");
+                builder.Append("<name>")
+                    .Append(SecurityElement.Escape(item.Name ?? string.Empty))
+                    .Append("</name>");
+                builder.Append("<dataType>")
+                    .Append(SecurityElement.Escape(item.DataType ?? string.Empty))
+                    .Append("</dataType>");
 
                 if (item.AllowedValues.Length > 0)
                 {
                     builder.Append("<allowedValueList>");
                     foreach (var allowedValue in item.AllowedValues)
                     {
-                        builder.Append("<allowedValue>" + DescriptionXmlBuilder.Escape(allowedValue) + "</allowedValue>");
+                        builder.Append("<allowedValue>")
+                            .Append(SecurityElement.Escape(allowedValue))
+                            .Append("</allowedValue>");
                     }
 
                     builder.Append("</allowedValueList>");

+ 7 - 7
Emby.Drawing/ImageProcessor.cs

@@ -448,21 +448,21 @@ namespace Emby.Drawing
         /// or
         /// filename.
         /// </exception>
-        public string GetCachePath(string path, string filename)
+        public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
         {
-            if (string.IsNullOrEmpty(path))
+            if (path.IsEmpty)
             {
-                throw new ArgumentNullException(nameof(path));
+                throw new ArgumentException("Path can't be empty.", nameof(path));
             }
 
-            if (string.IsNullOrEmpty(filename))
+            if (path.IsEmpty)
             {
-                throw new ArgumentNullException(nameof(filename));
+                throw new ArgumentException("Filename can't be empty.", nameof(filename));
             }
 
-            var prefix = filename.Substring(0, 1);
+            var prefix = filename.Slice(0, 1);
 
-            return Path.Combine(path, prefix, filename);
+            return Path.Join(path, prefix, filename);
         }
 
         /// <inheritdoc />

+ 30 - 30
Emby.Naming/Common/NamingOptions.cs

@@ -136,8 +136,8 @@ namespace Emby.Naming.Common
 
             CleanDateTimes = new[]
             {
-                @"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19\d{2}|20\d{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19\d{2}|20\d{2})*",
-                @"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19\d{2}|20\d{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19\d{2}|20\d{2})*"
+                @"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*",
+                @"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*"
             };
 
             CleanStrings = new[]
@@ -277,7 +277,7 @@ namespace Emby.Naming.Common
                 // This isn't a Kodi naming rule, but the expression below causes false positives,
                 // so we make sure this one gets tested first.
                 // "Foo Bar 889"
-                new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>\d{1,3})(-(?<endingepnumber>\d{2,3}))*[^\\\/x]*$")
+                new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/x]*$")
                 {
                     IsNamed = true
                 },
@@ -300,32 +300,32 @@ namespace Emby.Naming.Common
                 // *** End Kodi Standard Naming
 
                 // [bar] Foo - 1 [baz]
-                new EpisodeExpression(@".*?(\[.*?\])+.*?(?<seriesname>[\w\s]+?)[-\s_]+(?<epnumber>\d+).*$")
+                new EpisodeExpression(@".*?(\[.*?\])+.*?(?<seriesname>[\w\s]+?)[-\s_]+(?<epnumber>[0-9]+).*$")
                 {
                     IsNamed = true
                 },
-                new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>\d+)[xX](?<epnumber>\d+)[^\\\/]*$")
+                new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$")
                 {
                     IsNamed = true
                 },
 
-                new EpisodeExpression(@".*(\\|\/)[sS](?<seasonnumber>\d+)[x,X]?[eE](?<epnumber>\d+)[^\\\/]*$")
+                new EpisodeExpression(@".*(\\|\/)[sS](?<seasonnumber>[0-9]+)[x,X]?[eE](?<epnumber>[0-9]+)[^\\\/]*$")
                 {
                     IsNamed = true
                 },
 
-                new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d+))[^\\\/]*$")
+                new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]+))[^\\\/]*$")
                 {
                     IsNamed = true
                 },
 
-                new EpisodeExpression(@".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>\d{1,4})[xX\.]?[eE](?<epnumber>\d+)[^\\\/]*$")
+                new EpisodeExpression(@".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>[0-9]{1,4})[xX\.]?[eE](?<epnumber>[0-9]+)[^\\\/]*$")
                 {
                     IsNamed = true
                 },
 
                 // "01.avi"
-                new EpisodeExpression(@".*[\\\/](?<epnumber>\d+)(-(?<endingepnumber>\d+))*\.\w+$")
+                new EpisodeExpression(@".*[\\\/](?<epnumber>[0-9]+)(-(?<endingepnumber>[0-9]+))*\.\w+$")
                 {
                     IsOptimistic = true,
                     IsNamed = true
@@ -335,34 +335,34 @@ namespace Emby.Naming.Common
                 new EpisodeExpression(@"([0-9]+)-([0-9]+)"),
 
                 // "01 - blah.avi", "01-blah.avi"
-                new EpisodeExpression(@".*(\\|\/)(?<epnumber>\d{1,3})(-(?<endingepnumber>\d{2,3}))*\s?-\s?[^\\\/]*$")
+                new EpisodeExpression(@".*(\\|\/)(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*\s?-\s?[^\\\/]*$")
                 {
                     IsOptimistic = true,
                     IsNamed = true
                 },
 
                 // "01.blah.avi"
-                new EpisodeExpression(@".*(\\|\/)(?<epnumber>\d{1,3})(-(?<endingepnumber>\d{2,3}))*\.[^\\\/]+$")
+                new EpisodeExpression(@".*(\\|\/)(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*\.[^\\\/]+$")
                 {
                     IsOptimistic = true,
                     IsNamed = true
                 },
 
                 // "blah - 01.avi", "blah 2 - 01.avi", "blah - 01 blah.avi", "blah 2 - 01 blah", "blah - 01 - blah.avi", "blah 2 - 01 - blah"
-                new EpisodeExpression(@".*[\\\/][^\\\/]* - (?<epnumber>\d{1,3})(-(?<endingepnumber>\d{2,3}))*[^\\\/]*$")
+                new EpisodeExpression(@".*[\\\/][^\\\/]* - (?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/]*$")
                 {
                     IsOptimistic = true,
                     IsNamed = true
                 },
 
                 // "01 episode title.avi"
-                new EpisodeExpression(@"[Ss]eason[\._ ](?<seasonnumber>[0-9]+)[\\\/](?<epnumber>\d{1,3})([^\\\/]*)$")
+                new EpisodeExpression(@"[Ss]eason[\._ ](?<seasonnumber>[0-9]+)[\\\/](?<epnumber>[0-9]{1,3})([^\\\/]*)$")
                 {
                     IsOptimistic = true,
                     IsNamed = true
                 },
                 // "Episode 16", "Episode 16 - Title"
-                new EpisodeExpression(@".*[\\\/][^\\\/]* (?<epnumber>\d{1,3})(-(?<endingepnumber>\d{2,3}))*[^\\\/]*$")
+                new EpisodeExpression(@".*[\\\/][^\\\/]* (?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/]*$")
                 {
                     IsOptimistic = true,
                     IsNamed = true
@@ -625,17 +625,17 @@ namespace Emby.Naming.Common
             AudioBookPartsExpressions = new[]
             {
                 // Detect specified chapters, like CH 01
-                @"ch(?:apter)?[\s_-]?(?<chapter>\d+)",
+                @"ch(?:apter)?[\s_-]?(?<chapter>[0-9]+)",
                 // Detect specified parts, like Part 02
-                @"p(?:ar)?t[\s_-]?(?<part>\d+)",
+                @"p(?:ar)?t[\s_-]?(?<part>[0-9]+)",
                 // Chapter is often beginning of filename
-                @"^(?<chapter>\d+)",
+                "^(?<chapter>[0-9]+)",
                 // Part if often ending of filename
-                @"(?<part>\d+)$",
+                "(?<part>[0-9]+)$",
                 // Sometimes named as 0001_005 (chapter_part)
-                @"(?<chapter>\d+)_(?<part>\d+)",
+                "(?<chapter>[0-9]+)_(?<part>[0-9]+)",
                 // Some audiobooks are ripped from cd's, and will be named by disk number.
-                @"dis(?:c|k)[\s_-]?(?<chapter>\d+)"
+                @"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)"
             };
 
             var extensions = VideoFileExtensions.ToList();
@@ -675,16 +675,16 @@ namespace Emby.Naming.Common
 
             MultipleEpisodeExpressions = new string[]
             {
-                @".*(\\|\/)[sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3})((-| - )\d{1,4}[eExX](?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)[sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3})((-| - )\d{1,4}[xX][eE](?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)[sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3})((-| - )?[xXeE](?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)[sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3})(-[xE]?[eE]?(?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)(?<seriesname>((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3}))((-| - )\d{1,4}[xXeE](?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)(?<seriesname>((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3}))((-| - )\d{1,4}[xX][eE](?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)(?<seriesname>((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3}))((-| - )?[xXeE](?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)(?<seriesname>((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>\d{1,4})[xX](?<epnumber>\d{1,3}))(-[xX]?[eE]?(?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>\d{1,4})[xX\.]?[eE](?<epnumber>\d{1,3})((-| - )?[xXeE](?<endingepnumber>\d{1,3}))+[^\\\/]*$",
-                @".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>\d{1,4})[xX\.]?[eE](?<epnumber>\d{1,3})(-[xX]?[eE]?(?<endingepnumber>\d{1,3}))+[^\\\/]*$"
+                @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[xX][eE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )?[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})(-[xE]?[eE]?(?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))((-| - )[0-9]{1,4}[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))((-| - )[0-9]{1,4}[xX][eE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))((-| - )?[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))(-[xX]?[eE]?(?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>[0-9]{1,4})[xX\.]?[eE](?<epnumber>[0-9]{1,3})((-| - )?[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
+                @".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>[0-9]{1,4})[xX\.]?[eE](?<epnumber>[0-9]{1,3})(-[xX]?[eE]?(?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$"
             }.Select(i => new EpisodeExpression(i)
             {
                 IsNamed = true

+ 1 - 1
Emby.Naming/TV/SeasonPathParser.cs

@@ -77,7 +77,7 @@ namespace Emby.Naming.TV
 
             if (filename.StartsWith("s", StringComparison.OrdinalIgnoreCase))
             {
-                var testFilename = filename.Substring(1);
+                var testFilename = filename.AsSpan().Slice(1);
 
                 if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
                 {

+ 2 - 6
Emby.Server.Implementations/ApplicationHost.cs

@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations
         /// Gets or sets the application paths.
         /// </summary>
         /// <value>The application paths.</value>
-        protected ServerApplicationPaths ApplicationPaths { get; set; }
+        protected IServerApplicationPaths ApplicationPaths { get; set; }
 
         /// <summary>
         /// Gets or sets all concrete types.
@@ -235,7 +235,7 @@ namespace Emby.Server.Implementations
         /// Initializes a new instance of the <see cref="ApplicationHost" /> class.
         /// </summary>
         public ApplicationHost(
-            ServerApplicationPaths applicationPaths,
+            IServerApplicationPaths applicationPaths,
             ILoggerFactory loggerFactory,
             IStartupOptions options,
             IFileSystem fileSystem,
@@ -553,8 +553,6 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>();
             serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
 
-            serviceCollection.AddSingleton<IDisplayPreferencesRepository, SqliteDisplayPreferencesRepository>();
-
             serviceCollection.AddSingleton<IItemRepository, SqliteItemRepository>();
 
             serviceCollection.AddSingleton<IAuthenticationRepository, AuthenticationRepository>();
@@ -651,7 +649,6 @@ namespace Emby.Server.Implementations
             _httpServer = Resolve<IHttpServer>();
             _httpClient = Resolve<IHttpClient>();
 
-            ((SqliteDisplayPreferencesRepository)Resolve<IDisplayPreferencesRepository>()).Initialize();
             ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize();
 
             SetStaticProperties();
@@ -796,7 +793,6 @@ namespace Emby.Server.Implementations
             Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
 
             Resolve<INotificationManager>().AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>());
-            Resolve<IUserManager>().AddParts(GetExports<IAuthenticationProvider>(), GetExports<IPasswordResetProvider>());
 
             Resolve<IIsoManager>().AddParts(GetExports<IIsoMounter>());
         }

+ 10 - 14
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -1,5 +1,4 @@
 using System;
-using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
@@ -7,6 +6,7 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller.Channels;
@@ -22,6 +22,7 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Serialization;
+using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
@@ -45,10 +46,7 @@ namespace Emby.Server.Implementations.Channels
         private readonly IFileSystem _fileSystem;
         private readonly IJsonSerializer _jsonSerializer;
         private readonly IProviderManager _providerManager;
-
-        private readonly ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>> _channelItemMediaInfo =
-            new ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>>();
-
+        private readonly IMemoryCache _memoryCache;
         private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1);
 
         /// <summary>
@@ -63,6 +61,7 @@ namespace Emby.Server.Implementations.Channels
         /// <param name="userDataManager">The user data manager.</param>
         /// <param name="jsonSerializer">The JSON serializer.</param>
         /// <param name="providerManager">The provider manager.</param>
+        /// <param name="memoryCache">The memory cache.</param>
         public ChannelManager(
             IUserManager userManager,
             IDtoService dtoService,
@@ -72,7 +71,8 @@ namespace Emby.Server.Implementations.Channels
             IFileSystem fileSystem,
             IUserDataManager userDataManager,
             IJsonSerializer jsonSerializer,
-            IProviderManager providerManager)
+            IProviderManager providerManager,
+            IMemoryCache memoryCache)
         {
             _userManager = userManager;
             _dtoService = dtoService;
@@ -83,6 +83,7 @@ namespace Emby.Server.Implementations.Channels
             _userDataManager = userDataManager;
             _jsonSerializer = jsonSerializer;
             _providerManager = providerManager;
+            _memoryCache = memoryCache;
         }
 
         internal IChannel[] Channels { get; private set; }
@@ -417,20 +418,15 @@ namespace Emby.Server.Implementations.Channels
 
         private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken)
         {
-            if (_channelItemMediaInfo.TryGetValue(id, out Tuple<DateTime, List<MediaSourceInfo>> cachedInfo))
+            if (_memoryCache.TryGetValue(id, out List<MediaSourceInfo> cachedInfo))
             {
-                if ((DateTime.UtcNow - cachedInfo.Item1).TotalMinutes < 5)
-                {
-                    return cachedInfo.Item2;
-                }
+                return cachedInfo;
             }
 
             var mediaInfo = await channel.GetChannelItemMediaInfo(id, cancellationToken)
                    .ConfigureAwait(false);
             var list = mediaInfo.ToList();
-
-            var item2 = new Tuple<DateTime, List<MediaSourceInfo>>(DateTime.UtcNow, list);
-            _channelItemMediaInfo.AddOrUpdate(id, item2, (key, oldValue) => item2);
+            _memoryCache.CreateEntry(id).SetValue(list).SetAbsoluteExpiration(DateTimeOffset.UtcNow.AddMinutes(5));
 
             return list;
         }

+ 0 - 225
Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs

@@ -1,225 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Text.Json;
-using System.Threading;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Json;
-using MediaBrowser.Controller.Persistence;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
-using Microsoft.Extensions.Logging;
-using SQLitePCL.pretty;
-
-namespace Emby.Server.Implementations.Data
-{
-    /// <summary>
-    /// Class SQLiteDisplayPreferencesRepository.
-    /// </summary>
-    public class SqliteDisplayPreferencesRepository : BaseSqliteRepository, IDisplayPreferencesRepository
-    {
-        private readonly IFileSystem _fileSystem;
-
-        private readonly JsonSerializerOptions _jsonOptions;
-
-        public SqliteDisplayPreferencesRepository(ILogger<SqliteDisplayPreferencesRepository> logger, IApplicationPaths appPaths, IFileSystem fileSystem)
-            : base(logger)
-        {
-            _fileSystem = fileSystem;
-
-            _jsonOptions = JsonDefaults.GetOptions();
-
-            DbFilePath = Path.Combine(appPaths.DataPath, "displaypreferences.db");
-        }
-
-        /// <summary>
-        /// Gets the name of the repository.
-        /// </summary>
-        /// <value>The name.</value>
-        public string Name => "SQLite";
-
-        public void Initialize()
-        {
-            try
-            {
-                InitializeInternal();
-            }
-            catch (Exception ex)
-            {
-                Logger.LogError(ex, "Error loading database file. Will reset and retry.");
-
-                _fileSystem.DeleteFile(DbFilePath);
-
-                InitializeInternal();
-            }
-        }
-
-        /// <summary>
-        /// Opens the connection to the database.
-        /// </summary>
-        /// <returns>Task.</returns>
-        private void InitializeInternal()
-        {
-            string[] queries =
-            {
-                "create table if not exists userdisplaypreferences (id GUID NOT NULL, userId GUID NOT NULL, client text NOT NULL, data BLOB NOT NULL)",
-                "create unique index if not exists userdisplaypreferencesindex on userdisplaypreferences (id, userId, client)"
-            };
-
-            using (var connection = GetConnection())
-            {
-                connection.RunQueries(queries);
-            }
-        }
-
-        /// <summary>
-        /// Save the display preferences associated with an item in the repo.
-        /// </summary>
-        /// <param name="displayPreferences">The display preferences.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="client">The client.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, CancellationToken cancellationToken)
-        {
-            if (displayPreferences == null)
-            {
-                throw new ArgumentNullException(nameof(displayPreferences));
-            }
-
-            if (string.IsNullOrEmpty(displayPreferences.Id))
-            {
-                throw new ArgumentException("Display preferences has an invalid Id", nameof(displayPreferences));
-            }
-
-            cancellationToken.ThrowIfCancellationRequested();
-
-            using (var connection = GetConnection())
-            {
-                connection.RunInTransaction(
-                    db => SaveDisplayPreferences(displayPreferences, userId, client, db),
-                    TransactionMode);
-            }
-        }
-
-        private void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, IDatabaseConnection connection)
-        {
-            var serialized = JsonSerializer.SerializeToUtf8Bytes(displayPreferences, _jsonOptions);
-
-            using (var statement = connection.PrepareStatement("replace into userdisplaypreferences (id, userid, client, data) values (@id, @userId, @client, @data)"))
-            {
-                statement.TryBind("@id", new Guid(displayPreferences.Id).ToByteArray());
-                statement.TryBind("@userId", userId.ToByteArray());
-                statement.TryBind("@client", client);
-                statement.TryBind("@data", serialized);
-
-                statement.MoveNext();
-            }
-        }
-
-        /// <summary>
-        /// Save all display preferences associated with a user in the repo.
-        /// </summary>
-        /// <param name="displayPreferences">The display preferences.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public void SaveAllDisplayPreferences(IEnumerable<DisplayPreferences> displayPreferences, Guid userId, CancellationToken cancellationToken)
-        {
-            if (displayPreferences == null)
-            {
-                throw new ArgumentNullException(nameof(displayPreferences));
-            }
-
-            cancellationToken.ThrowIfCancellationRequested();
-
-            using (var connection = GetConnection())
-            {
-                connection.RunInTransaction(
-                    db =>
-                    {
-                        foreach (var displayPreference in displayPreferences)
-                        {
-                            SaveDisplayPreferences(displayPreference, userId, displayPreference.Client, db);
-                        }
-                    },
-                    TransactionMode);
-            }
-        }
-
-        /// <summary>
-        /// Gets the display preferences.
-        /// </summary>
-        /// <param name="displayPreferencesId">The display preferences id.</param>
-        /// <param name="userId">The user id.</param>
-        /// <param name="client">The client.</param>
-        /// <returns>Task{DisplayPreferences}.</returns>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, Guid userId, string client)
-        {
-            if (string.IsNullOrEmpty(displayPreferencesId))
-            {
-                throw new ArgumentNullException(nameof(displayPreferencesId));
-            }
-
-            var guidId = displayPreferencesId.GetMD5();
-
-            using (var connection = GetConnection(true))
-            {
-                using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where id = @id and userId=@userId and client=@client"))
-                {
-                    statement.TryBind("@id", guidId.ToByteArray());
-                    statement.TryBind("@userId", userId.ToByteArray());
-                    statement.TryBind("@client", client);
-
-                    foreach (var row in statement.ExecuteQuery())
-                    {
-                        return Get(row);
-                    }
-                }
-            }
-
-            return new DisplayPreferences
-            {
-                Id = guidId.ToString("N", CultureInfo.InvariantCulture)
-            };
-        }
-
-        /// <summary>
-        /// Gets all display preferences for the given user.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <returns>Task{DisplayPreferences}.</returns>
-        /// <exception cref="ArgumentNullException">item</exception>
-        public IEnumerable<DisplayPreferences> GetAllDisplayPreferences(Guid userId)
-        {
-            var list = new List<DisplayPreferences>();
-
-            using (var connection = GetConnection(true))
-            using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where userId=@userId"))
-            {
-                statement.TryBind("@userId", userId.ToByteArray());
-
-                foreach (var row in statement.ExecuteQuery())
-                {
-                    list.Add(Get(row));
-                }
-            }
-
-            return list;
-        }
-
-        private DisplayPreferences Get(IReadOnlyList<IResultSetValue> row)
-            => JsonSerializer.Deserialize<DisplayPreferences>(row[0].ToBlob(), _jsonOptions);
-
-        public void SaveDisplayPreferences(DisplayPreferences displayPreferences, string userId, string client, CancellationToken cancellationToken)
-            => SaveDisplayPreferences(displayPreferences, new Guid(userId), client, cancellationToken);
-
-        public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, string userId, string client)
-            => GetDisplayPreferences(displayPreferencesId, new Guid(userId), client);
-    }
-}

+ 54 - 139
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -9,6 +9,7 @@ using System.Text;
 using System.Text.Json;
 using System.Threading;
 using Emby.Server.Implementations.Playlists;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Json;
 using MediaBrowser.Controller;
@@ -400,6 +401,8 @@ namespace Emby.Server.Implementations.Data
             "OwnerId"
         };
 
+        private static readonly string _retriveItemColumnsSelectQuery = $"select {string.Join(',', _retriveItemColumns)} from TypedBaseItems where guid = @guid";
+
         private static readonly string[] _mediaStreamSaveColumns =
         {
             "ItemId",
@@ -439,6 +442,12 @@ namespace Emby.Server.Implementations.Data
             "ColorTransfer"
         };
 
+        private static readonly string _mediaStreamSaveColumnsInsertQuery =
+            $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values ";
+
+        private static readonly string _mediaStreamSaveColumnsSelectQuery =
+            $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId";
+
         private static readonly string[] _mediaAttachmentSaveColumns =
         {
             "ItemId",
@@ -450,102 +459,15 @@ namespace Emby.Server.Implementations.Data
             "MIMEType"
         };
 
-        private static readonly string _mediaAttachmentInsertPrefix;
-
-        private static string GetSaveItemCommandText()
-        {
-            var saveColumns = new[]
-            {
-                "guid",
-                "type",
-                "data",
-                "Path",
-                "StartDate",
-                "EndDate",
-                "ChannelId",
-                "IsMovie",
-                "IsSeries",
-                "EpisodeTitle",
-                "IsRepeat",
-                "CommunityRating",
-                "CustomRating",
-                "IndexNumber",
-                "IsLocked",
-                "Name",
-                "OfficialRating",
-                "MediaType",
-                "Overview",
-                "ParentIndexNumber",
-                "PremiereDate",
-                "ProductionYear",
-                "ParentId",
-                "Genres",
-                "InheritedParentalRatingValue",
-                "SortName",
-                "ForcedSortName",
-                "RunTimeTicks",
-                "Size",
-                "DateCreated",
-                "DateModified",
-                "PreferredMetadataLanguage",
-                "PreferredMetadataCountryCode",
-                "Width",
-                "Height",
-                "DateLastRefreshed",
-                "DateLastSaved",
-                "IsInMixedFolder",
-                "LockedFields",
-                "Studios",
-                "Audio",
-                "ExternalServiceId",
-                "Tags",
-                "IsFolder",
-                "UnratedType",
-                "TopParentId",
-                "TrailerTypes",
-                "CriticRating",
-                "CleanName",
-                "PresentationUniqueKey",
-                "OriginalTitle",
-                "PrimaryVersionId",
-                "DateLastMediaAdded",
-                "Album",
-                "IsVirtualItem",
-                "SeriesName",
-                "UserDataKey",
-                "SeasonName",
-                "SeasonId",
-                "SeriesId",
-                "ExternalSeriesId",
-                "Tagline",
-                "ProviderIds",
-                "Images",
-                "ProductionLocations",
-                "ExtraIds",
-                "TotalBitrate",
-                "ExtraType",
-                "Artists",
-                "AlbumArtists",
-                "ExternalId",
-                "SeriesPresentationUniqueKey",
-                "ShowId",
-                "OwnerId"
-            };
-
-            var saveItemCommandCommandText = "replace into TypedBaseItems (" + string.Join(",", saveColumns) + ") values (";
-
-            for (var i = 0; i < saveColumns.Length; i++)
-            {
-                if (i != 0)
-                {
-                    saveItemCommandCommandText += ",";
-                }
+        private static readonly string _mediaAttachmentSaveColumnsSelectQuery =
+            $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId";
 
-                saveItemCommandCommandText += "@" + saveColumns[i];
-            }
+        private static readonly string _mediaAttachmentInsertPrefix;
 
-            return saveItemCommandCommandText + ")";
-        }
+        private const string SaveItemCommandText =
+            @"replace into TypedBaseItems
+            (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId)
+            values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)";
 
         /// <summary>
         /// Save a standard item in the repo.
@@ -636,7 +558,7 @@ namespace Emby.Server.Implementations.Data
         {
             var statements = PrepareAll(db, new string[]
             {
-                GetSaveItemCommandText(),
+                SaveItemCommandText,
                 "delete from AncestorIds where ItemId=@ItemId"
             }).ToList();
 
@@ -1110,7 +1032,8 @@ namespace Emby.Server.Implementations.Data
                     continue;
                 }
 
-                str.Append(ToValueString(i) + "|");
+                str.Append(ToValueString(i))
+                    .Append('|');
             }
 
             str.Length -= 1; // Remove last |
@@ -1225,7 +1148,7 @@ namespace Emby.Server.Implementations.Data
 
             using (var connection = GetConnection(true))
             {
-                using (var statement = PrepareStatement(connection, "select " + string.Join(",", _retriveItemColumns) + " from TypedBaseItems where guid = @guid"))
+                using (var statement = PrepareStatement(connection, _retriveItemColumnsSelectQuery))
                 {
                     statement.TryBind("@guid", id);
 
@@ -2471,7 +2394,7 @@ namespace Emby.Server.Implementations.Data
                 var item = query.SimilarTo;
 
                 var builder = new StringBuilder();
-                builder.Append("(");
+                builder.Append('(');
 
                 if (string.IsNullOrEmpty(item.OfficialRating))
                 {
@@ -2509,7 +2432,7 @@ namespace Emby.Server.Implementations.Data
             if (!string.IsNullOrEmpty(query.SearchTerm))
             {
                 var builder = new StringBuilder();
-                builder.Append("(");
+                builder.Append('(');
 
                 builder.Append("((CleanName like @SearchTermStartsWith or (OriginalTitle not null and OriginalTitle like @SearchTermStartsWith)) * 10)");
 
@@ -2775,82 +2698,82 @@ namespace Emby.Server.Implementations.Data
 
         private string FixUnicodeChars(string buffer)
         {
-            if (buffer.IndexOf('\u2013') > -1)
+            if (buffer.IndexOf('\u2013', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2013', '-'); // en dash
             }
 
-            if (buffer.IndexOf('\u2014') > -1)
+            if (buffer.IndexOf('\u2014', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2014', '-'); // em dash
             }
 
-            if (buffer.IndexOf('\u2015') > -1)
+            if (buffer.IndexOf('\u2015', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2015', '-'); // horizontal bar
             }
 
-            if (buffer.IndexOf('\u2017') > -1)
+            if (buffer.IndexOf('\u2017', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2017', '_'); // double low line
             }
 
-            if (buffer.IndexOf('\u2018') > -1)
+            if (buffer.IndexOf('\u2018', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2018', '\''); // left single quotation mark
             }
 
-            if (buffer.IndexOf('\u2019') > -1)
+            if (buffer.IndexOf('\u2019', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2019', '\''); // right single quotation mark
             }
 
-            if (buffer.IndexOf('\u201a') > -1)
+            if (buffer.IndexOf('\u201a', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark
             }
 
-            if (buffer.IndexOf('\u201b') > -1)
+            if (buffer.IndexOf('\u201b', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark
             }
 
-            if (buffer.IndexOf('\u201c') > -1)
+            if (buffer.IndexOf('\u201c', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark
             }
 
-            if (buffer.IndexOf('\u201d') > -1)
+            if (buffer.IndexOf('\u201d', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark
             }
 
-            if (buffer.IndexOf('\u201e') > -1)
+            if (buffer.IndexOf('\u201e', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark
             }
 
-            if (buffer.IndexOf('\u2026') > -1)
+            if (buffer.IndexOf('\u2026', StringComparison.Ordinal) > -1)
             {
-                buffer = buffer.Replace("\u2026", "..."); // horizontal ellipsis
+                buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis
             }
 
-            if (buffer.IndexOf('\u2032') > -1)
+            if (buffer.IndexOf('\u2032', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2032', '\''); // prime
             }
 
-            if (buffer.IndexOf('\u2033') > -1)
+            if (buffer.IndexOf('\u2033', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u2033', '\"'); // double prime
             }
 
-            if (buffer.IndexOf('\u0060') > -1)
+            if (buffer.IndexOf('\u0060', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u0060', '\''); // grave accent
             }
 
-            if (buffer.IndexOf('\u00B4') > -1)
+            if (buffer.IndexOf('\u00B4', StringComparison.Ordinal) > -1)
             {
                 buffer = buffer.Replace('\u00B4', '\''); // acute accent
             }
@@ -2999,7 +2922,6 @@ namespace Emby.Server.Implementations.Data
             {
                 connection.RunInTransaction(db =>
                 {
-
                     var statements = PrepareAll(db, statementTexts).ToList();
 
                     if (!isReturningZeroItems)
@@ -4669,8 +4591,12 @@ namespace Emby.Server.Implementations.Data
 
             if (query.BlockUnratedItems.Length > 1)
             {
-                var inClause = string.Join(",", query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'"));
-                whereClauses.Add(string.Format("(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", inClause));
+                var inClause = string.Join(',', query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'"));
+                whereClauses.Add(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))",
+                        inClause));
             }
 
             if (query.ExcludeInheritedTags.Length > 0)
@@ -4679,7 +4605,7 @@ namespace Emby.Server.Implementations.Data
                 if (statement == null)
                 {
                     int index = 0;
-                    string excludedTags = string.Join(",", query.ExcludeInheritedTags.Select(t => paramName + index++));
+                    string excludedTags = string.Join(',', query.ExcludeInheritedTags.Select(t => paramName + index++));
                     whereClauses.Add("((select CleanValue from itemvalues where ItemId=Guid and Type=6 and cleanvalue in (" + excludedTags + ")) is null)");
                 }
                 else
@@ -5238,7 +5164,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
             {
                 if (i > 0)
                 {
-                    insertText.Append(",");
+                    insertText.Append(',');
                 }
 
                 insertText.AppendFormat("(@ItemId, @AncestorId{0}, @AncestorIdText{0})", i.ToString(CultureInfo.InvariantCulture));
@@ -5890,10 +5816,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 throw new ArgumentNullException(nameof(query));
             }
 
-            var cmdText = "select "
-                        + string.Join(",", _mediaStreamSaveColumns)
-                        + " from mediastreams where"
-                        + " ItemId=@ItemId";
+            var cmdText = _mediaStreamSaveColumnsSelectQuery;
 
             if (query.Type.HasValue)
             {
@@ -5972,15 +5895,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
 
             while (startIndex < streams.Count)
             {
-                var insertText = new StringBuilder("insert into mediastreams (");
-                foreach (var column in _mediaStreamSaveColumns)
-                {
-                    insertText.Append(column).Append(',');
-                }
-
-                // Remove last comma
-                insertText.Length--;
-                insertText.Append(") values ");
+                var insertText = new StringBuilder(_mediaStreamSaveColumnsInsertQuery);
 
                 var endIndex = Math.Min(streams.Count, startIndex + Limit);
 
@@ -6247,10 +6162,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
                 throw new ArgumentNullException(nameof(query));
             }
 
-            var cmdText = "select "
-                        + string.Join(",", _mediaAttachmentSaveColumns)
-                        + " from mediaattachments where"
-                        + " ItemId=@ItemId";
+            var cmdText = _mediaAttachmentSaveColumnsSelectQuery;
 
             if (query.Index.HasValue)
             {
@@ -6331,7 +6243,10 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
 
                     foreach (var column in _mediaAttachmentSaveColumns.Skip(1))
                     {
-                        insertText.Append("@" + column + index + ",");
+                        insertText.Append('@')
+                            .Append(column)
+                            .Append(index)
+                            .Append(',');
                     }
 
                     insertText.Length -= 1;

+ 12 - 11
Emby.Server.Implementations/Devices/DeviceManager.cs

@@ -5,8 +5,8 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Linq;
-using Jellyfin.Data.Enums;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
@@ -17,16 +17,17 @@ using MediaBrowser.Model.Events;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Caching.Memory;
 
 namespace Emby.Server.Implementations.Devices
 {
     public class DeviceManager : IDeviceManager
     {
+        private readonly IMemoryCache _memoryCache;
         private readonly IJsonSerializer _json;
         private readonly IUserManager _userManager;
         private readonly IServerConfigurationManager _config;
         private readonly IAuthenticationRepository _authRepo;
-        private readonly Dictionary<string, ClientCapabilities> _capabilitiesCache;
         private readonly object _capabilitiesSyncLock = new object();
 
         public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
@@ -35,13 +36,14 @@ namespace Emby.Server.Implementations.Devices
             IAuthenticationRepository authRepo,
             IJsonSerializer json,
             IUserManager userManager,
-            IServerConfigurationManager config)
+            IServerConfigurationManager config,
+            IMemoryCache memoryCache)
         {
             _json = json;
             _userManager = userManager;
             _config = config;
+            _memoryCache = memoryCache;
             _authRepo = authRepo;
-            _capabilitiesCache = new Dictionary<string, ClientCapabilities>(StringComparer.OrdinalIgnoreCase);
         }
 
         public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
@@ -51,8 +53,7 @@ namespace Emby.Server.Implementations.Devices
 
             lock (_capabilitiesSyncLock)
             {
-                _capabilitiesCache[deviceId] = capabilities;
-
+                _memoryCache.CreateEntry(deviceId).SetValue(capabilities);
                 _json.SerializeToFile(capabilities, path);
             }
         }
@@ -71,13 +72,13 @@ namespace Emby.Server.Implementations.Devices
 
         public ClientCapabilities GetCapabilities(string id)
         {
-            lock (_capabilitiesSyncLock)
+            if (_memoryCache.TryGetValue(id, out ClientCapabilities result))
             {
-                if (_capabilitiesCache.TryGetValue(id, out var result))
-                {
-                    return result;
-                }
+                return result;
+            }
 
+            lock (_capabilitiesSyncLock)
+            {
                 var path = Path.Combine(GetDevicePath(id), "capabilities.json");
                 try
                 {

+ 4 - 4
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -24,7 +24,7 @@
 
   <ItemGroup>
     <PackageReference Include="IPNetwork2" Version="2.5.211" />
-    <PackageReference Include="Jellyfin.XmlTv" Version="10.6.0-pre1" />
+    <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" Version="2.2.0" />
@@ -37,10 +37,10 @@
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.6" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.6" />
     <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.6" />
-    <PackageReference Include="Mono.Nat" Version="2.0.1" />
+    <PackageReference Include="Mono.Nat" Version="2.0.2" />
     <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.3.1" />
     <PackageReference Include="ServiceStack.Text.Core" Version="5.9.0" />
-    <PackageReference Include="sharpcompress" Version="0.25.1" />
+    <PackageReference Include="sharpcompress" Version="0.26.0" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
     <PackageReference Include="DotNet.Glob" Version="3.0.9" />
   </ItemGroup>
@@ -53,7 +53,7 @@
     <TargetFramework>netstandard2.1</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'" >true</TreatWarningsAsErrors>
+    <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors>
   </PropertyGroup>
 
   <!-- Code Analyzers-->

+ 0 - 2
Emby.Server.Implementations/HttpServer/FileWriter.cs

@@ -29,7 +29,6 @@ namespace Emby.Server.Implementations.HttpServer
 
         private readonly IStreamHelper _streamHelper;
         private readonly ILogger _logger;
-        private readonly IFileSystem _fileSystem;
 
         /// <summary>
         /// The _options.
@@ -49,7 +48,6 @@ namespace Emby.Server.Implementations.HttpServer
             }
 
             _streamHelper = streamHelper;
-            _fileSystem = fileSystem;
 
             Path = path;
             _logger = logger;

+ 2 - 2
Emby.Server.Implementations/HttpServer/HttpListenerHost.cs

@@ -449,7 +449,7 @@ namespace Emby.Server.Implementations.HttpServer
                 if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase))
                 {
                     httpRes.StatusCode = 200;
-                    foreach(var (key, value) in GetDefaultCorsHeaders(httpReq))
+                    foreach (var (key, value) in GetDefaultCorsHeaders(httpReq))
                     {
                         httpRes.Headers.Add(key, value);
                     }
@@ -486,7 +486,7 @@ namespace Emby.Server.Implementations.HttpServer
                 var handler = GetServiceHandler(httpReq);
                 if (handler != null)
                 {
-                    await handler.ProcessRequestAsync(this, httpReq, httpRes, _logger, cancellationToken).ConfigureAwait(false);
+                    await handler.ProcessRequestAsync(this, httpReq, httpRes, cancellationToken).ConfigureAwait(false);
                 }
                 else
                 {

+ 0 - 4
Emby.Server.Implementations/HttpServer/Security/AuthService.cs

@@ -13,26 +13,22 @@ using MediaBrowser.Controller.Security;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Services;
 using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.HttpServer.Security
 {
     public class AuthService : IAuthService
     {
-        private readonly ILogger<AuthService> _logger;
         private readonly IAuthorizationContext _authorizationContext;
         private readonly ISessionManager _sessionManager;
         private readonly IServerConfigurationManager _config;
         private readonly INetworkManager _networkManager;
 
         public AuthService(
-            ILogger<AuthService> logger,
             IAuthorizationContext authorizationContext,
             IServerConfigurationManager config,
             ISessionManager sessionManager,
             INetworkManager networkManager)
         {
-            _logger = logger;
             _authorizationContext = authorizationContext;
             _config = config;
             _sessionManager = sessionManager;

+ 2 - 1
Emby.Server.Implementations/IO/FileRefresher.cs

@@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.IO
         private readonly List<string> _affectedPaths = new List<string>();
         private readonly object _timerLock = new object();
         private Timer _timer;
+        private bool _disposed;
 
         public FileRefresher(string path, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger logger)
         {
@@ -213,11 +214,11 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
-        private bool _disposed;
         public void Dispose()
         {
             _disposed = true;
             DisposeTimer();
+            GC.SuppressFinalize(this);
         }
     }
 }

+ 2 - 8
Emby.Server.Implementations/Images/ArtistImageProvider.cs

@@ -11,7 +11,6 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
@@ -25,14 +24,9 @@ namespace Emby.Server.Implementations.Images
     /// </summary>
     public class ArtistImageProvider : BaseDynamicImageProvider<MusicArtist>
     {
-        /// <summary>
-        /// The library manager.
-        /// </summary>
-        private readonly ILibraryManager _libraryManager;
-
-        public ArtistImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor)
+        public ArtistImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor)
+            : base(fileSystem, providerManager, applicationPaths, imageProcessor)
         {
-            _libraryManager = libraryManager;
         }
 
         /// <summary>

+ 1 - 1
Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs

@@ -3,7 +3,7 @@
 using System;
 using System.Collections.Generic;
 using System.IO;
-using Emby.Server.Implementations.Images;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;

+ 1 - 1
Emby.Server.Implementations/Images/FolderImageProvider.cs

@@ -1,7 +1,7 @@
 #pragma warning disable CS1591
 
 using System.Collections.Generic;
-using Emby.Server.Implementations.Images;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;

+ 1 - 0
Emby.Server.Implementations/Images/GenreImageProvider.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 
 using System.Collections.Generic;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;

+ 1 - 1
Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs

@@ -77,7 +77,7 @@ namespace Emby.Server.Implementations.Library
                 if (parent != null)
                 {
                     // Don't resolve these into audio files
-                    if (string.Equals(Path.GetFileNameWithoutExtension(filename), BaseItem.ThemeSongFilename)
+                    if (string.Equals(Path.GetFileNameWithoutExtension(filename), BaseItem.ThemeSongFilename, StringComparison.Ordinal)
                         && _libraryManager.IsAudioFile(filename))
                     {
                         return true;

+ 12 - 12
Emby.Server.Implementations/Library/ExclusiveLiveStream.cs

@@ -11,6 +11,17 @@ namespace Emby.Server.Implementations.Library
 {
     public class ExclusiveLiveStream : ILiveStream
     {
+        private readonly Func<Task> _closeFn;
+
+        public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func<Task> closeFn)
+        {
+            MediaSource = mediaSource;
+            EnableStreamSharing = false;
+            _closeFn = closeFn;
+            ConsumerCount = 1;
+            UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+        }
+
         public int ConsumerCount { get; set; }
 
         public string OriginalStreamId { get; set; }
@@ -21,18 +32,7 @@ namespace Emby.Server.Implementations.Library
 
         public MediaSourceInfo MediaSource { get; set; }
 
-        public string UniqueId { get; private set; }
-
-        private Func<Task> _closeFn;
-
-        public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func<Task> closeFn)
-        {
-            MediaSource = mediaSource;
-            EnableStreamSharing = false;
-            _closeFn = closeFn;
-            ConsumerCount = 1;
-            UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
-        }
+        public string UniqueId { get; }
 
         public Task Close()
         {

+ 20 - 3
Emby.Server.Implementations/Library/IgnorePatterns.cs

@@ -18,7 +18,21 @@ namespace Emby.Server.Implementations.Library
         {
             "**/small.jpg",
             "**/albumart.jpg",
-            "**/*sample*",
+
+            // We have neither non-greedy matching or character group repetitions, working around that here.
+            // https://github.com/dazinator/DotNet.Glob#patterns
+            // .*/sample\..{1,5}
+            "**/sample.?",
+            "**/sample.??",
+            "**/sample.???", // Matches sample.mkv
+            "**/sample.????", // Matches sample.webm
+            "**/sample.?????",
+            "**/*.sample.?",
+            "**/*.sample.??",
+            "**/*.sample.???",
+            "**/*.sample.????",
+            "**/*.sample.?????",
+            "**/sample/*",
 
             // Directories
             "**/metadata/**",
@@ -64,10 +78,13 @@ namespace Emby.Server.Implementations.Library
             "**/.grab/**",
             "**/.grab",
 
-            // Unix hidden files and directories
-            "**/.*/**",
+            // Unix hidden files
             "**/.*",
 
+            // Mac - if you ever remove the above.
+            // "**/._*",
+            // "**/.DS_Store",
+
             // thumbs.db
             "**/thumbs.db",
 

+ 114 - 124
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -1,7 +1,6 @@
 #pragma warning disable CS1591
 
 using System;
-using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
@@ -46,11 +45,11 @@ using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Tasks;
 using MediaBrowser.Providers.MediaInfo;
+using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 using Genre = MediaBrowser.Controller.Entities.Genre;
 using Person = MediaBrowser.Controller.Entities.Person;
-using SortOrder = MediaBrowser.Model.Entities.SortOrder;
 using VideoResolver = Emby.Naming.Video.VideoResolver;
 
 namespace Emby.Server.Implementations.Library
@@ -60,7 +59,10 @@ namespace Emby.Server.Implementations.Library
     /// </summary>
     public class LibraryManager : ILibraryManager
     {
+        private const string ShortcutFileExtension = ".mblink";
+
         private readonly ILogger<LibraryManager> _logger;
+        private readonly IMemoryCache _memoryCache;
         private readonly ITaskManager _taskManager;
         private readonly IUserManager _userManager;
         private readonly IUserDataManager _userDataRepository;
@@ -72,66 +74,26 @@ namespace Emby.Server.Implementations.Library
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IFileSystem _fileSystem;
         private readonly IItemRepository _itemRepository;
-        private readonly ConcurrentDictionary<Guid, BaseItem> _libraryItemsCache;
         private readonly IImageProcessor _imageProcessor;
 
-        private NamingOptions _namingOptions;
-        private string[] _videoFileExtensions;
-
-        private ILibraryMonitor LibraryMonitor => _libraryMonitorFactory.Value;
-
-        private IProviderManager ProviderManager => _providerManagerFactory.Value;
-
-        private IUserViewManager UserViewManager => _userviewManagerFactory.Value;
-
-        /// <summary>
-        /// Gets or sets the postscan tasks.
-        /// </summary>
-        /// <value>The postscan tasks.</value>
-        private ILibraryPostScanTask[] PostscanTasks { get; set; }
-
-        /// <summary>
-        /// Gets or sets the intro providers.
-        /// </summary>
-        /// <value>The intro providers.</value>
-        private IIntroProvider[] IntroProviders { get; set; }
-
-        /// <summary>
-        /// Gets or sets the list of entity resolution ignore rules.
-        /// </summary>
-        /// <value>The entity resolution ignore rules.</value>
-        private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; }
-
-        /// <summary>
-        /// Gets or sets the list of currently registered entity resolvers.
-        /// </summary>
-        /// <value>The entity resolvers enumerable.</value>
-        private IItemResolver[] EntityResolvers { get; set; }
-
-        private IMultiItemResolver[] MultiItemResolvers { get; set; }
-
         /// <summary>
-        /// Gets or sets the comparers.
+        /// The _root folder sync lock.
         /// </summary>
-        /// <value>The comparers.</value>
-        private IBaseItemComparer[] Comparers { get; set; }
+        private readonly object _rootFolderSyncLock = new object();
+        private readonly object _userRootFolderSyncLock = new object();
 
-        /// <summary>
-        /// Occurs when [item added].
-        /// </summary>
-        public event EventHandler<ItemChangeEventArgs> ItemAdded;
+        private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24);
 
-        /// <summary>
-        /// Occurs when [item updated].
-        /// </summary>
-        public event EventHandler<ItemChangeEventArgs> ItemUpdated;
+        private NamingOptions _namingOptions;
+        private string[] _videoFileExtensions;
 
         /// <summary>
-        /// Occurs when [item removed].
+        /// The _root folder.
         /// </summary>
-        public event EventHandler<ItemChangeEventArgs> ItemRemoved;
+        private volatile AggregateFolder _rootFolder;
+        private volatile UserRootFolder _userRootFolder;
 
-        public bool IsScanRunning { get; private set; }
+        private bool _wizardCompleted;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="LibraryManager" /> class.
@@ -149,6 +111,7 @@ namespace Emby.Server.Implementations.Library
         /// <param name="mediaEncoder">The media encoder.</param>
         /// <param name="itemRepository">The item repository.</param>
         /// <param name="imageProcessor">The image processor.</param>
+        /// <param name="memoryCache">The memory cache.</param>
         public LibraryManager(
             IServerApplicationHost appHost,
             ILogger<LibraryManager> logger,
@@ -162,7 +125,8 @@ namespace Emby.Server.Implementations.Library
             Lazy<IUserViewManager> userviewManagerFactory,
             IMediaEncoder mediaEncoder,
             IItemRepository itemRepository,
-            IImageProcessor imageProcessor)
+            IImageProcessor imageProcessor,
+            IMemoryCache memoryCache)
         {
             _appHost = appHost;
             _logger = logger;
@@ -177,8 +141,7 @@ namespace Emby.Server.Implementations.Library
             _mediaEncoder = mediaEncoder;
             _itemRepository = itemRepository;
             _imageProcessor = imageProcessor;
-
-            _libraryItemsCache = new ConcurrentDictionary<Guid, BaseItem>();
+            _memoryCache = memoryCache;
 
             _configurationManager.ConfigurationUpdated += ConfigurationUpdated;
 
@@ -186,37 +149,19 @@ namespace Emby.Server.Implementations.Library
         }
 
         /// <summary>
-        /// Adds the parts.
+        /// Occurs when [item added].
         /// </summary>
-        /// <param name="rules">The rules.</param>
-        /// <param name="resolvers">The resolvers.</param>
-        /// <param name="introProviders">The intro providers.</param>
-        /// <param name="itemComparers">The item comparers.</param>
-        /// <param name="postscanTasks">The post scan tasks.</param>
-        public void AddParts(
-            IEnumerable<IResolverIgnoreRule> rules,
-            IEnumerable<IItemResolver> resolvers,
-            IEnumerable<IIntroProvider> introProviders,
-            IEnumerable<IBaseItemComparer> itemComparers,
-            IEnumerable<ILibraryPostScanTask> postscanTasks)
-        {
-            EntityResolutionIgnoreRules = rules.ToArray();
-            EntityResolvers = resolvers.OrderBy(i => i.Priority).ToArray();
-            MultiItemResolvers = EntityResolvers.OfType<IMultiItemResolver>().ToArray();
-            IntroProviders = introProviders.ToArray();
-            Comparers = itemComparers.ToArray();
-            PostscanTasks = postscanTasks.ToArray();
-        }
+        public event EventHandler<ItemChangeEventArgs> ItemAdded;
 
         /// <summary>
-        /// The _root folder.
+        /// Occurs when [item updated].
         /// </summary>
-        private volatile AggregateFolder _rootFolder;
+        public event EventHandler<ItemChangeEventArgs> ItemUpdated;
 
         /// <summary>
-        /// The _root folder sync lock.
+        /// Occurs when [item removed].
         /// </summary>
-        private readonly object _rootFolderSyncLock = new object();
+        public event EventHandler<ItemChangeEventArgs> ItemRemoved;
 
         /// <summary>
         /// Gets the root folder.
@@ -241,7 +186,68 @@ namespace Emby.Server.Implementations.Library
             }
         }
 
-        private bool _wizardCompleted;
+        private ILibraryMonitor LibraryMonitor => _libraryMonitorFactory.Value;
+
+        private IProviderManager ProviderManager => _providerManagerFactory.Value;
+
+        private IUserViewManager UserViewManager => _userviewManagerFactory.Value;
+
+        /// <summary>
+        /// Gets or sets the postscan tasks.
+        /// </summary>
+        /// <value>The postscan tasks.</value>
+        private ILibraryPostScanTask[] PostscanTasks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the intro providers.
+        /// </summary>
+        /// <value>The intro providers.</value>
+        private IIntroProvider[] IntroProviders { get; set; }
+
+        /// <summary>
+        /// Gets or sets the list of entity resolution ignore rules.
+        /// </summary>
+        /// <value>The entity resolution ignore rules.</value>
+        private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; }
+
+        /// <summary>
+        /// Gets or sets the list of currently registered entity resolvers.
+        /// </summary>
+        /// <value>The entity resolvers enumerable.</value>
+        private IItemResolver[] EntityResolvers { get; set; }
+
+        private IMultiItemResolver[] MultiItemResolvers { get; set; }
+
+        /// <summary>
+        /// Gets or sets the comparers.
+        /// </summary>
+        /// <value>The comparers.</value>
+        private IBaseItemComparer[] Comparers { get; set; }
+
+        public bool IsScanRunning { get; private set; }
+
+        /// <summary>
+        /// Adds the parts.
+        /// </summary>
+        /// <param name="rules">The rules.</param>
+        /// <param name="resolvers">The resolvers.</param>
+        /// <param name="introProviders">The intro providers.</param>
+        /// <param name="itemComparers">The item comparers.</param>
+        /// <param name="postscanTasks">The post scan tasks.</param>
+        public void AddParts(
+            IEnumerable<IResolverIgnoreRule> rules,
+            IEnumerable<IItemResolver> resolvers,
+            IEnumerable<IIntroProvider> introProviders,
+            IEnumerable<IBaseItemComparer> itemComparers,
+            IEnumerable<ILibraryPostScanTask> postscanTasks)
+        {
+            EntityResolutionIgnoreRules = rules.ToArray();
+            EntityResolvers = resolvers.OrderBy(i => i.Priority).ToArray();
+            MultiItemResolvers = EntityResolvers.OfType<IMultiItemResolver>().ToArray();
+            IntroProviders = introProviders.ToArray();
+            Comparers = itemComparers.ToArray();
+            PostscanTasks = postscanTasks.ToArray();
+        }
 
         /// <summary>
         /// Records the configuration values.
@@ -293,7 +299,7 @@ namespace Emby.Server.Implementations.Library
                 }
             }
 
-            _libraryItemsCache.AddOrUpdate(item.Id, item, delegate { return item; });
+            _memoryCache.CreateEntry(item.Id).SetValue(item);
         }
 
         public void DeleteItem(BaseItem item, DeleteOptions options)
@@ -441,7 +447,7 @@ namespace Emby.Server.Implementations.Library
                 _itemRepository.DeleteItem(child.Id);
             }
 
-            _libraryItemsCache.TryRemove(item.Id, out BaseItem removed);
+            _memoryCache.Remove(item.Id);
 
             ReportItemRemoved(item, parent);
         }
@@ -511,8 +517,8 @@ namespace Emby.Server.Implementations.Library
             {
                 // Try to normalize paths located underneath program-data in an attempt to make them more portable
                 key = key.Substring(_configurationManager.ApplicationPaths.ProgramDataPath.Length)
-                    .TrimStart(new[] { '/', '\\' })
-                    .Replace("/", "\\");
+                    .TrimStart('/', '\\')
+                    .Replace('/', '\\');
             }
 
             if (forceCaseInsensitive || !_configurationManager.Configuration.EnableCaseSensitiveItemIds)
@@ -775,14 +781,11 @@ namespace Emby.Server.Implementations.Library
             return rootFolder;
         }
 
-        private volatile UserRootFolder _userRootFolder;
-        private readonly object _syncLock = new object();
-
         public Folder GetUserRootFolder()
         {
             if (_userRootFolder == null)
             {
-                lock (_syncLock)
+                lock (_userRootFolderSyncLock)
                 {
                     if (_userRootFolder == null)
                     {
@@ -1245,7 +1248,7 @@ namespace Emby.Server.Implementations.Library
                 throw new ArgumentException("Guid can't be empty", nameof(id));
             }
 
-            if (_libraryItemsCache.TryGetValue(id, out BaseItem item))
+            if (_memoryCache.TryGetValue(id, out BaseItem item))
             {
                 return item;
             }
@@ -1332,7 +1335,7 @@ namespace Emby.Server.Implementations.Library
 
             return new QueryResult<BaseItem>
             {
-                Items = _itemRepository.GetItemList(query).ToArray()
+                Items = _itemRepository.GetItemList(query)
             };
         }
 
@@ -1463,11 +1466,9 @@ namespace Emby.Server.Implementations.Library
                 return _itemRepository.GetItems(query);
             }
 
-            var list = _itemRepository.GetItemList(query);
-
             return new QueryResult<BaseItem>
             {
-                Items = list
+                Items = _itemRepository.GetItemList(query)
             };
         }
 
@@ -1590,7 +1591,6 @@ namespace Emby.Server.Implementations.Library
         public async Task<IEnumerable<Video>> GetIntros(BaseItem item, User user)
         {
             var tasks = IntroProviders
-                .OrderBy(i => i.GetType().Name.Contains("Default", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
                 .Take(1)
                 .Select(i => GetIntros(i, item, user));
 
@@ -1876,7 +1876,8 @@ namespace Emby.Server.Implementations.Library
             }
 
             var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path != null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
-            if (outdated.Length == 0)
+            // Skip image processing if current or live tv source
+            if (outdated.Length == 0 || item.SourceType != SourceType.Library)
             {
                 RegisterItem(item);
                 return;
@@ -1945,12 +1946,9 @@ namespace Emby.Server.Implementations.Library
         /// <summary>
         /// Updates the item.
         /// </summary>
-        public void UpdateItems(IEnumerable<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
+        public void UpdateItems(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
         {
-            // Don't iterate multiple times
-            var itemsList = items.ToList();
-
-            foreach (var item in itemsList)
+            foreach (var item in items)
             {
                 if (item.IsFileProtocol)
                 {
@@ -1962,11 +1960,11 @@ namespace Emby.Server.Implementations.Library
                 UpdateImages(item, updateReason >= ItemUpdateType.ImageUpdate);
             }
 
-            _itemRepository.SaveItems(itemsList, cancellationToken);
+            _itemRepository.SaveItems(items, cancellationToken);
 
             if (ItemUpdated != null)
             {
-                foreach (var item in itemsList)
+                foreach (var item in items)
                 {
                     // With the live tv guide this just creates too much noise
                     if (item.SourceType != SourceType.Library)
@@ -2189,8 +2187,6 @@ namespace Emby.Server.Implementations.Library
                 .FirstOrDefault(i => !string.IsNullOrEmpty(i));
         }
 
-        private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24);
-
         public UserView GetNamedView(
             User user,
             string name,
@@ -2488,14 +2484,9 @@ namespace Emby.Server.Implementations.Library
 
             var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd;
 
-            var episodeInfo = episode.IsFileProtocol ?
-                resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming) :
-                new Naming.TV.EpisodeInfo();
-
-            if (episodeInfo == null)
-            {
-                episodeInfo = new Naming.TV.EpisodeInfo();
-            }
+            var episodeInfo = episode.IsFileProtocol
+                ? resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming) ?? new Naming.TV.EpisodeInfo()
+                : new Naming.TV.EpisodeInfo();
 
             try
             {
@@ -2503,11 +2494,13 @@ namespace Emby.Server.Implementations.Library
                 if (libraryOptions.EnableEmbeddedEpisodeInfos && string.Equals(episodeInfo.Container, "mp4", StringComparison.OrdinalIgnoreCase))
                 {
                     // Read from metadata
-                    var mediaInfo = _mediaEncoder.GetMediaInfo(new MediaInfoRequest
-                    {
-                        MediaSource = episode.GetMediaSources(false)[0],
-                        MediaType = DlnaProfileType.Video
-                    }, CancellationToken.None).GetAwaiter().GetResult();
+                    var mediaInfo = _mediaEncoder.GetMediaInfo(
+                        new MediaInfoRequest
+                        {
+                            MediaSource = episode.GetMediaSources(false)[0],
+                            MediaType = DlnaProfileType.Video
+                        },
+                        CancellationToken.None).GetAwaiter().GetResult();
                     if (mediaInfo.ParentIndexNumber > 0)
                     {
                         episodeInfo.SeasonNumber = mediaInfo.ParentIndexNumber;
@@ -2665,7 +2658,7 @@ namespace Emby.Server.Implementations.Library
 
             var videos = videoListResolver.Resolve(fileSystemChildren);
 
-            var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files.First().Path, StringComparison.OrdinalIgnoreCase));
+            var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase));
 
             if (currentVideo != null)
             {
@@ -2682,9 +2675,7 @@ namespace Emby.Server.Implementations.Library
                 .Select(video =>
                 {
                     // Try to retrieve it from the db. If we don't find it, use the resolved version
-                    var dbItem = GetItemById(video.Id) as Trailer;
-
-                    if (dbItem != null)
+                    if (GetItemById(video.Id) is Trailer dbItem)
                     {
                         video = dbItem;
                     }
@@ -3011,8 +3002,6 @@ namespace Emby.Server.Implementations.Library
             });
         }
 
-        private const string ShortcutFileExtension = ".mblink";
-
         public void AddMediaPath(string virtualFolderName, MediaPathInfo pathInfo)
         {
             AddMediaPathInternal(virtualFolderName, pathInfo, true);
@@ -3206,7 +3195,8 @@ namespace Emby.Server.Implementations.Library
 
             if (!Directory.Exists(virtualFolderPath))
             {
-                throw new FileNotFoundException(string.Format("The media collection {0} does not exist", virtualFolderName));
+                throw new FileNotFoundException(
+                    string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
             }
 
             var shortcut = _fileSystem.GetFilePaths(virtualFolderPath, true)

+ 12 - 12
Emby.Server.Implementations/Library/LiveStreamHelper.cs

@@ -23,9 +23,8 @@ namespace Emby.Server.Implementations.Library
     {
         private readonly IMediaEncoder _mediaEncoder;
         private readonly ILogger _logger;
-
-        private IJsonSerializer _json;
-        private IApplicationPaths _appPaths;
+        private readonly IJsonSerializer _json;
+        private readonly IApplicationPaths _appPaths;
 
         public LiveStreamHelper(IMediaEncoder mediaEncoder, ILogger logger, IJsonSerializer json, IApplicationPaths appPaths)
         {
@@ -72,13 +71,14 @@ namespace Emby.Server.Implementations.Library
 
                 mediaSource.AnalyzeDurationMs = 3000;
 
-                mediaInfo = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest
-                {
-                    MediaSource = mediaSource,
-                    MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video,
-                    ExtractChapters = false
-
-                }, cancellationToken).ConfigureAwait(false);
+                mediaInfo = await _mediaEncoder.GetMediaInfo(
+                    new MediaInfoRequest
+                    {
+                        MediaSource = mediaSource,
+                        MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video,
+                        ExtractChapters = false
+                    },
+                    cancellationToken).ConfigureAwait(false);
 
                 if (cacheFilePath != null)
                 {
@@ -126,7 +126,7 @@ namespace Emby.Server.Implementations.Library
                 mediaSource.RunTimeTicks = null;
             }
 
-            var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio);
+            var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio);
 
             if (audioStream == null || audioStream.Index == -1)
             {
@@ -137,7 +137,7 @@ namespace Emby.Server.Implementations.Library
                 mediaSource.DefaultAudioStreamIndex = audioStream.Index;
             }
 
-            var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video);
+            var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video);
             if (videoStream != null)
             {
                 if (!videoStream.BitRate.HasValue)

+ 23 - 25
Emby.Server.Implementations/Library/MediaSourceManager.cs

@@ -29,6 +29,9 @@ namespace Emby.Server.Implementations.Library
 {
     public class MediaSourceManager : IMediaSourceManager, IDisposable
     {
+        // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
+        private const char LiveStreamIdDelimeter = '_';
+
         private readonly IItemRepository _itemRepo;
         private readonly IUserManager _userManager;
         private readonly ILibraryManager _libraryManager;
@@ -40,6 +43,9 @@ namespace Emby.Server.Implementations.Library
         private readonly ILocalizationManager _localizationManager;
         private readonly IApplicationPaths _appPaths;
 
+        private readonly Dictionary<string, ILiveStream> _openStreams = new Dictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase);
+        private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1);
+
         private IMediaSourceProvider[] _providers;
 
         public MediaSourceManager(
@@ -368,7 +374,6 @@ namespace Emby.Server.Implementations.Library
                 }
             }
 
-
             var preferredSubs = string.IsNullOrEmpty(user.SubtitleLanguagePreference)
                 ? Array.Empty<string>() : NormalizeLanguage(user.SubtitleLanguagePreference);
 
@@ -451,9 +456,6 @@ namespace Emby.Server.Implementations.Library
             .ToList();
         }
 
-        private readonly Dictionary<string, ILiveStream> _openStreams = new Dictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase);
-        private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1);
-
         public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
         {
             await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -619,12 +621,14 @@ namespace Emby.Server.Implementations.Library
 
             if (liveStreamInfo is IDirectStreamProvider)
             {
-                var info = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest
-                {
-                    MediaSource = mediaSource,
-                    ExtractChapters = false,
-                    MediaType = DlnaProfileType.Video
-                }, cancellationToken).ConfigureAwait(false);
+                var info = await _mediaEncoder.GetMediaInfo(
+                    new MediaInfoRequest
+                    {
+                        MediaSource = mediaSource,
+                        ExtractChapters = false,
+                        MediaType = DlnaProfileType.Video
+                    },
+                    cancellationToken).ConfigureAwait(false);
 
                 mediaSource.MediaStreams = info.MediaStreams;
                 mediaSource.Container = info.Container;
@@ -855,24 +859,21 @@ namespace Emby.Server.Implementations.Library
             }
         }
 
-        // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
-        private const char LiveStreamIdDelimeter = '_';
-
-        private Tuple<IMediaSourceProvider, string> GetProvider(string key)
+        private (IMediaSourceProvider, string) GetProvider(string key)
         {
             if (string.IsNullOrEmpty(key))
             {
-                throw new ArgumentException("key");
+                throw new ArgumentException("Key can't be empty.", nameof(key));
             }
 
             var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2);
 
             var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase));
 
-            var splitIndex = key.IndexOf(LiveStreamIdDelimeter);
+            var splitIndex = key.IndexOf(LiveStreamIdDelimeter, StringComparison.Ordinal);
             var keyId = key.Substring(splitIndex + 1);
 
-            return new Tuple<IMediaSourceProvider, string>(provider, keyId);
+            return (provider, keyId);
         }
 
         /// <summary>
@@ -881,9 +882,9 @@ namespace Emby.Server.Implementations.Library
         public void Dispose()
         {
             Dispose(true);
+            GC.SuppressFinalize(this);
         }
 
-        private readonly object _disposeLock = new object();
         /// <summary>
         /// Releases unmanaged and - optionally - managed resources.
         /// </summary>
@@ -892,15 +893,12 @@ namespace Emby.Server.Implementations.Library
         {
             if (dispose)
             {
-                lock (_disposeLock)
+                foreach (var key in _openStreams.Keys.ToList())
                 {
-                    foreach (var key in _openStreams.Keys.ToList())
-                    {
-                        var task = CloseLiveStream(key);
-
-                        Task.WaitAll(task);
-                    }
+                    CloseLiveStream(key).GetAwaiter().GetResult();
                 }
+
+                _liveStreamSemaphore.Dispose();
             }
         }
     }

+ 1 - 1
Emby.Server.Implementations/Library/MediaStreamSelector.cs

@@ -89,7 +89,7 @@ namespace Emby.Server.Implementations.Library
             }
 
             // load forced subs if we have found no suitable full subtitles
-            stream = stream ?? streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase));
+            stream ??= streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase));
 
             if (stream != null)
             {

+ 1 - 1
Emby.Server.Implementations/Library/MusicManager.cs

@@ -4,12 +4,12 @@ using System;
 using System.Collections.Generic;
 using System.Linq;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Playlists;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
 

+ 0 - 5
Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs

@@ -1,6 +1,5 @@
 using System.Globalization;
 using Emby.Naming.TV;
-using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Globalization;
@@ -13,7 +12,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
     /// </summary>
     public class SeasonResolver : FolderResolver<Season>
     {
-        private readonly IServerConfigurationManager _config;
         private readonly ILibraryManager _libraryManager;
         private readonly ILocalizationManager _localization;
         private readonly ILogger<SeasonResolver> _logger;
@@ -21,17 +19,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
         /// <summary>
         /// Initializes a new instance of the <see cref="SeasonResolver"/> class.
         /// </summary>
-        /// <param name="config">The config.</param>
         /// <param name="libraryManager">The library manager.</param>
         /// <param name="localization">The localization.</param>
         /// <param name="logger">The logger.</param>
         public SeasonResolver(
-            IServerConfigurationManager config,
             ILibraryManager libraryManager,
             ILocalizationManager localization,
             ILogger<SeasonResolver> logger)
         {
-            _config = config;
             _libraryManager = libraryManager;
             _localization = localization;
             _logger = logger;

+ 7 - 13
Emby.Server.Implementations/Library/SearchEngine.cs

@@ -4,12 +4,12 @@ using System;
 using System.Collections.Generic;
 using System.Linq;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Search;
 using Microsoft.Extensions.Logging;
@@ -20,13 +20,11 @@ namespace Emby.Server.Implementations.Library
 {
     public class SearchEngine : ISearchEngine
     {
-        private readonly ILogger<SearchEngine> _logger;
         private readonly ILibraryManager _libraryManager;
         private readonly IUserManager _userManager;
 
-        public SearchEngine(ILogger<SearchEngine> logger, ILibraryManager libraryManager, IUserManager userManager)
+        public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
         {
-            _logger = logger;
             _libraryManager = libraryManager;
             _userManager = userManager;
         }
@@ -34,11 +32,7 @@ namespace Emby.Server.Implementations.Library
         public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
         {
             User user = null;
-
-            if (query.UserId.Equals(Guid.Empty))
-            {
-            }
-            else
+            if (query.UserId != Guid.Empty)
             {
                 user = _userManager.GetUserById(query.UserId);
             }
@@ -48,19 +42,19 @@ namespace Emby.Server.Implementations.Library
 
             if (query.StartIndex.HasValue)
             {
-                results = results.Skip(query.StartIndex.Value).ToList();
+                results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
             }
 
             if (query.Limit.HasValue)
             {
-                results = results.Take(query.Limit.Value).ToList();
+                results = results.GetRange(0, query.Limit.Value);
             }
 
             return new QueryResult<SearchHintInfo>
             {
                 TotalRecordCount = totalRecordCount,
 
-                Items = results.ToArray()
+                Items = results
             };
         }
 
@@ -85,7 +79,7 @@ namespace Emby.Server.Implementations.Library
 
             if (string.IsNullOrEmpty(searchTerm))
             {
-                throw new ArgumentNullException("SearchTerm can't be empty.", nameof(searchTerm));
+                throw new ArgumentException("SearchTerm can't be empty.", nameof(query));
             }
 
             searchTerm = searchTerm.Trim().RemoveDiacritics();

+ 0 - 4
Emby.Server.Implementations/Library/UserDataManager.cs

@@ -13,7 +13,6 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
-using Microsoft.Extensions.Logging;
 using Book = MediaBrowser.Controller.Entities.Book;
 
 namespace Emby.Server.Implementations.Library
@@ -28,18 +27,15 @@ namespace Emby.Server.Implementations.Library
         private readonly ConcurrentDictionary<string, UserItemData> _userData =
             new ConcurrentDictionary<string, UserItemData>(StringComparer.OrdinalIgnoreCase);
 
-        private readonly ILogger<UserDataManager> _logger;
         private readonly IServerConfigurationManager _config;
         private readonly IUserManager _userManager;
         private readonly IUserDataRepository _repository;
 
         public UserDataManager(
-            ILogger<UserDataManager> logger,
             IServerConfigurationManager config,
             IUserManager userManager,
             IUserDataRepository repository)
         {
-            _logger = logger;
             _config = config;
             _userManager = userManager;
             _repository = repository;

+ 1 - 0
Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs

@@ -12,6 +12,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using System.Xml;
 using Emby.Server.Implementations.Library;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;

+ 2 - 2
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -461,7 +461,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
         private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms(
             ListingsProviderInfo info,
             List<string> programIds,
-           CancellationToken cancellationToken)
+            CancellationToken cancellationToken)
         {
             if (programIds.Count == 0)
             {
@@ -474,7 +474,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             {
                 var imageId = i.Substring(0, 10);
 
-                if (!imageIdString.Contains(imageId))
+                if (!imageIdString.Contains(imageId, StringComparison.Ordinal))
                 {
                     imageIdString += "\"" + imageId + "\",";
                 }

+ 3 - 6
Emby.Server.Implementations/LiveTv/LiveTvManager.cs

@@ -4,6 +4,7 @@ using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
+using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
 using Emby.Server.Implementations.Library;
@@ -28,7 +29,6 @@ using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.LiveTv;
 using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Tasks;
 using Microsoft.Extensions.Logging;
 using Episode = MediaBrowser.Controller.Entities.TV.Episode;
@@ -54,7 +54,6 @@ namespace Emby.Server.Implementations.LiveTv
         private readonly ILibraryManager _libraryManager;
         private readonly ITaskManager _taskManager;
         private readonly ILocalizationManager _localization;
-        private readonly IJsonSerializer _jsonSerializer;
         private readonly IFileSystem _fileSystem;
         private readonly IChannelManager _channelManager;
         private readonly LiveTvDtoService _tvDtoService;
@@ -73,7 +72,6 @@ namespace Emby.Server.Implementations.LiveTv
             ILibraryManager libraryManager,
             ITaskManager taskManager,
             ILocalizationManager localization,
-            IJsonSerializer jsonSerializer,
             IFileSystem fileSystem,
             IChannelManager channelManager,
             LiveTvDtoService liveTvDtoService)
@@ -85,7 +83,6 @@ namespace Emby.Server.Implementations.LiveTv
             _libraryManager = libraryManager;
             _taskManager = taskManager;
             _localization = localization;
-            _jsonSerializer = jsonSerializer;
             _fileSystem = fileSystem;
             _dtoService = dtoService;
             _userDataManager = userDataManager;
@@ -2234,7 +2231,7 @@ namespace Emby.Server.Implementations.LiveTv
 
         public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true)
         {
-            info = _jsonSerializer.DeserializeFromString<TunerHostInfo>(_jsonSerializer.SerializeToString(info));
+            info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.Serialize(info));
 
             var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
 
@@ -2278,7 +2275,7 @@ namespace Emby.Server.Implementations.LiveTv
         {
             // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider
             // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider
-            info = _jsonSerializer.DeserializeFromString<ListingsProviderInfo>(_jsonSerializer.SerializeToString(info));
+            info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.Serialize(info));
 
             var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
 

+ 7 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs

@@ -195,7 +195,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 while (!sr.EndOfStream)
                 {
                     string line = StripXML(sr.ReadLine());
-                    if (line.Contains("Channel"))
+                    if (line.Contains("Channel", StringComparison.Ordinal))
                     {
                         LiveTvTunerStatus status;
                         var index = line.IndexOf("Channel", StringComparison.OrdinalIgnoreCase);
@@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
         private static string StripXML(string source)
         {
+            if (string.IsNullOrEmpty(source))
+            {
+                return string.Empty;
+            }
+
             char[] buffer = new char[source.Length];
             int bufferIndex = 0;
             bool inside = false;
@@ -270,7 +275,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
                 for (int i = 0; i < model.TunerCount; ++i)
                 {
-                    var name = string.Format("Tuner {0}", i + 1);
+                    var name = string.Format(CultureInfo.InvariantCulture, "Tuner {0}", i + 1);
                     var currentChannel = "none"; // @todo Get current channel and map back to Station Id
                     var isAvailable = await manager.CheckTunerAvailability(ipInfo, i, cancellationToken).ConfigureAwait(false);
                     var status = isAvailable ? LiveTvTunerStatus.Available : LiveTvTunerStatus.LiveTv;

+ 6 - 5
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs

@@ -77,7 +77,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         }
     }
 
-    public class HdHomerunManager : IDisposable
+    public sealed class HdHomerunManager : IDisposable
     {
         public const int HdHomeRunPort = 65001;
 
@@ -105,6 +105,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                     StopStreaming(socket).GetAwaiter().GetResult();
                 }
             }
+
+            GC.SuppressFinalize(this);
         }
 
         public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken)
@@ -162,7 +164,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                     }
 
                     _activeTuner = i;
-                    var lockKeyString = string.Format("{0:d}", lockKeyValue);
+                    var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue);
                     var lockkeyMsg = CreateSetMessage(i, "lockkey", lockKeyString, null);
                     await stream.WriteAsync(lockkeyMsg, 0, lockkeyMsg.Length, cancellationToken).ConfigureAwait(false);
                     int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
@@ -173,8 +175,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                         continue;
                     }
 
-                    var commandList = commands.GetCommands();
-                    foreach (var command in commandList)
+                    foreach (var command in commands.GetCommands())
                     {
                         var channelMsg = CreateSetMessage(i, command.Item1, command.Item2, lockKeyValue);
                         await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
@@ -188,7 +189,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                         }
                     }
 
-                    var targetValue = string.Format("rtp://{0}:{1}", localIp, localPort);
+                    var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIp, localPort);
                     var targetMsg = CreateSetMessage(i, "target", targetValue, lockKeyValue);
 
                     await stream.WriteAsync(targetMsg, 0, targetMsg.Length, cancellationToken).ConfigureAwait(false);

+ 12 - 13
Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs

@@ -158,15 +158,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
         private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
         {
             var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
-            var nameInExtInf = nameParts.Length > 1 ? nameParts[nameParts.Length - 1].Trim() : null;
+            var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty;
 
             string numberString = null;
             string attributeValue;
-            double doubleValue;
 
             if (attributes.TryGetValue("tvg-chno", out attributeValue))
             {
-                if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out doubleValue))
+                if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
                 {
                     numberString = attributeValue;
                 }
@@ -176,36 +175,36 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             {
                 if (attributes.TryGetValue("tvg-id", out attributeValue))
                 {
-                    if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out doubleValue))
+                    if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
                     {
                         numberString = attributeValue;
                     }
                     else if (attributes.TryGetValue("channel-id", out attributeValue))
                     {
-                        if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out doubleValue))
+                        if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
                         {
                             numberString = attributeValue;
                         }
                     }
                 }
 
-                if (String.IsNullOrWhiteSpace(numberString))
+                if (string.IsNullOrWhiteSpace(numberString))
                 {
                     // Using this as a fallback now as this leads to Problems with channels like "5 USA"
                     // where 5 isnt ment to be the channel number
                     // Check for channel number with the format from SatIp
                     // #EXTINF:0,84. VOX Schweiz
                     // #EXTINF:0,84.0 - VOX Schweiz
-                    if (!string.IsNullOrWhiteSpace(nameInExtInf))
+                    if (!nameInExtInf.IsEmpty && !nameInExtInf.IsWhiteSpace())
                     {
                         var numberIndex = nameInExtInf.IndexOf(' ');
                         if (numberIndex > 0)
                         {
-                            var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' });
+                            var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' });
 
-                            if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out var number))
+                            if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
                             {
-                                numberString = numberPart;
+                                numberString = numberPart.ToString();
                             }
                         }
                     }
@@ -231,7 +230,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
                 {
                     try
                     {
-                        numberString = Path.GetFileNameWithoutExtension(mediaUrl.Split('/').Last());
+                        numberString = Path.GetFileNameWithoutExtension(mediaUrl.Split('/')[^1]);
 
                         if (!IsValidChannelNumber(numberString))
                         {
@@ -258,7 +257,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
                 return false;
             }
 
-            if (!double.TryParse(numberString, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
+            if (!double.TryParse(numberString, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
             {
                 return false;
             }
@@ -281,7 +280,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
                 {
                     var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' });
 
-                    if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out var number))
+                    if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
                     {
                         // channel.Number = number.ToString();
                         nameInExtInf = nameInExtInf.Substring(numberIndex + 1).Trim(new[] { ' ', '-' });

+ 2 - 2
Emby.Server.Implementations/Localization/Core/de.json

@@ -101,8 +101,8 @@
     "TaskCleanTranscode": "Lösche Transkodier Pfad",
     "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche dazu eingestellt sind automatisch zu updaten und installiert sie.",
     "TaskUpdatePlugins": "Update Plugins",
-    "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schausteller und Regisseure in deinen Bibliotheken.",
-    "TaskRefreshPeople": "Erneuere Schausteller",
+    "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.",
+    "TaskRefreshPeople": "Erneuere Schauspieler",
     "TaskCleanLogsDescription": "Lösche Log Dateien die älter als {0} Tage sind.",
     "TaskCleanLogs": "Lösche Log Pfad",
     "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.",

+ 1 - 1
Emby.Server.Implementations/Localization/Core/zh-TW.json

@@ -92,7 +92,7 @@
     "HeaderRecordingGroups": "錄製組",
     "Inherit": "繼承",
     "SubtitleDownloadFailureFromForItem": "無法為 {1} 從 {0} 下載字幕",
-    "TaskDownloadMissingSubtitlesDescription": "在網路上透過描述資料搜尋遺失的字幕。",
+    "TaskDownloadMissingSubtitlesDescription": "在網路上透過中繼資料搜尋遺失的字幕。",
     "TaskDownloadMissingSubtitles": "下載遺失的字幕",
     "TaskRefreshChannels": "重新整理頻道",
     "TaskUpdatePlugins": "更新插件",

+ 4 - 4
Emby.Server.Implementations/Localization/LocalizationManager.cs

@@ -247,7 +247,7 @@ namespace Emby.Server.Implementations.Localization
             }
 
             // Try splitting by : to handle "Germany: FSK 18"
-            var index = rating.IndexOf(':');
+            var index = rating.IndexOf(':', StringComparison.Ordinal);
             if (index != -1)
             {
                 rating = rating.Substring(index).TrimStart(':').Trim();
@@ -312,12 +312,12 @@ namespace Emby.Server.Implementations.Localization
                 throw new ArgumentNullException(nameof(culture));
             }
 
-            const string prefix = "Core";
-            var key = prefix + culture;
+            const string Prefix = "Core";
+            var key = Prefix + culture;
 
             return _dictionaries.GetOrAdd(
                 key,
-                f => GetDictionary(prefix, culture, DefaultCulture + ".json").GetAwaiter().GetResult());
+                f => GetDictionary(Prefix, culture, DefaultCulture + ".json").GetAwaiter().GetResult());
         }
 
         private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename)

+ 23 - 20
Emby.Server.Implementations/Net/UdpSocket.cs

@@ -15,13 +15,11 @@ namespace Emby.Server.Implementations.Net
     public sealed class UdpSocket : ISocket, IDisposable
     {
         private Socket _socket;
-        private int _localPort;
+        private readonly int _localPort;
         private bool _disposed = false;
 
         public Socket Socket => _socket;
 
-        public IPAddress LocalIPAddress { get; }
-
         private readonly SocketAsyncEventArgs _receiveSocketAsyncEventArgs = new SocketAsyncEventArgs()
         {
             SocketFlags = SocketFlags.None
@@ -51,18 +49,33 @@ namespace Emby.Server.Implementations.Net
             InitReceiveSocketAsyncEventArgs();
         }
 
+        public UdpSocket(Socket socket, IPEndPoint endPoint)
+        {
+            if (socket == null)
+            {
+                throw new ArgumentNullException(nameof(socket));
+            }
+
+            _socket = socket;
+            _socket.Connect(endPoint);
+
+            InitReceiveSocketAsyncEventArgs();
+        }
+
+        public IPAddress LocalIPAddress { get; }
+
         private void InitReceiveSocketAsyncEventArgs()
         {
             var receiveBuffer = new byte[8192];
             _receiveSocketAsyncEventArgs.SetBuffer(receiveBuffer, 0, receiveBuffer.Length);
-            _receiveSocketAsyncEventArgs.Completed += _receiveSocketAsyncEventArgs_Completed;
+            _receiveSocketAsyncEventArgs.Completed += OnReceiveSocketAsyncEventArgsCompleted;
 
             var sendBuffer = new byte[8192];
             _sendSocketAsyncEventArgs.SetBuffer(sendBuffer, 0, sendBuffer.Length);
-            _sendSocketAsyncEventArgs.Completed += _sendSocketAsyncEventArgs_Completed;
+            _sendSocketAsyncEventArgs.Completed += OnSendSocketAsyncEventArgsCompleted;
         }
 
-        private void _receiveSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e)
+        private void OnReceiveSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e)
         {
             var tcs = _currentReceiveTaskCompletionSource;
             if (tcs != null)
@@ -86,7 +99,7 @@ namespace Emby.Server.Implementations.Net
             }
         }
 
-        private void _sendSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e)
+        private void OnSendSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e)
         {
             var tcs = _currentSendTaskCompletionSource;
             if (tcs != null)
@@ -104,19 +117,6 @@ namespace Emby.Server.Implementations.Net
             }
         }
 
-        public UdpSocket(Socket socket, IPEndPoint endPoint)
-        {
-            if (socket == null)
-            {
-                throw new ArgumentNullException(nameof(socket));
-            }
-
-            _socket = socket;
-            _socket.Connect(endPoint);
-
-            InitReceiveSocketAsyncEventArgs();
-        }
-
         public IAsyncResult BeginReceive(byte[] buffer, int offset, int count, AsyncCallback callback)
         {
             ThrowIfDisposed();
@@ -247,6 +247,7 @@ namespace Emby.Server.Implementations.Net
             }
         }
 
+        /// <inheritdoc />
         public void Dispose()
         {
             if (_disposed)
@@ -255,6 +256,8 @@ namespace Emby.Server.Implementations.Net
             }
 
             _socket?.Dispose();
+            _receiveSocketAsyncEventArgs.Dispose();
+            _sendSocketAsyncEventArgs.Dispose();
             _currentReceiveTaskCompletionSource?.TrySetCanceled();
             _currentSendTaskCompletionSource?.TrySetCanceled();
 

+ 2 - 2
Emby.Server.Implementations/Networking/NetworkManager.cs

@@ -165,7 +165,7 @@ namespace Emby.Server.Implementations.Networking
                 (octet[0] == 127) || // RFC1122
                 (octet[0] == 169 && octet[1] == 254)) // RFC3927
             {
-                return false;
+                return true;
             }
 
             if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint))
@@ -390,7 +390,7 @@ namespace Emby.Server.Implementations.Networking
                         var host = uri.DnsSafeHost;
                         _logger.LogDebug("Resolving host {0}", host);
 
-                        address = GetIpAddresses(host).Result.FirstOrDefault();
+                        address = GetIpAddresses(host).GetAwaiter().GetResult().FirstOrDefault();
 
                         if (address != null)
                         {

+ 20 - 44
Emby.Server.Implementations/Playlists/PlaylistManager.cs

@@ -349,16 +349,14 @@ namespace Emby.Server.Implementations.Playlists
                         AlbumTitle = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
-                    var hasArtist = child as IHasArtist;
-                    if (hasArtist != null)
+                    if (child is IHasArtist hasArtist)
                     {
-                        entry.TrackArtist = hasArtist.Artists.FirstOrDefault();
+                        entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -385,16 +383,14 @@ namespace Emby.Server.Implementations.Playlists
                         AlbumTitle = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
-                    var hasArtist = child as IHasArtist;
-                    if (hasArtist != null)
+                    if (child is IHasArtist hasArtist)
                     {
-                        entry.TrackArtist = hasArtist.Artists.FirstOrDefault();
+                        entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -411,8 +407,10 @@ namespace Emby.Server.Implementations.Playlists
 
             if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
             {
-                var playlist = new M3uPlaylist();
-                playlist.IsExtended = true;
+                var playlist = new M3uPlaylist
+                {
+                    IsExtended = true
+                };
                 foreach (var child in item.GetLinkedChildren())
                 {
                     var entry = new M3uPlaylistEntry()
@@ -422,10 +420,9 @@ namespace Emby.Server.Implementations.Playlists
                         Album = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -453,10 +450,9 @@ namespace Emby.Server.Implementations.Playlists
                         Album = child.Album
                     };
 
-                    var hasAlbumArtist = child as IHasAlbumArtist;
-                    if (hasAlbumArtist != null)
+                    if (child is IHasAlbumArtist hasAlbumArtist)
                     {
-                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault();
+                        entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null;
                     }
 
                     if (child.RunTimeTicks.HasValue)
@@ -514,7 +510,7 @@ namespace Emby.Server.Implementations.Playlists
 
             if (!folderPath.EndsWith(Path.DirectorySeparatorChar))
             {
-                folderPath = folderPath + Path.DirectorySeparatorChar;
+                folderPath += Path.DirectorySeparatorChar;
             }
 
             var folderUri = new Uri(folderPath);
@@ -537,32 +533,12 @@ namespace Emby.Server.Implementations.Playlists
             return relativePath;
         }
 
-        private static string UnEscape(string content)
-        {
-            if (content == null)
-            {
-                return content;
-            }
-
-            return content.Replace("&amp;", "&").Replace("&apos;", "'").Replace("&quot;", "\"").Replace("&gt;", ">").Replace("&lt;", "<");
-        }
-
-        private static string Escape(string content)
-        {
-            if (content == null)
-            {
-                return null;
-            }
-
-            return content.Replace("&", "&amp;").Replace("'", "&apos;").Replace("\"", "&quot;").Replace(">", "&gt;").Replace("<", "&lt;");
-        }
-
         public Folder GetPlaylistsFolder(Guid userId)
         {
-            var typeName = "PlaylistsFolder";
+            const string TypeName = "PlaylistsFolder";
 
-            return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal)) ??
-                _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal));
+            return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)) ??
+                _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal));
         }
     }
 }

+ 1 - 6
Emby.Server.Implementations/ScheduledTasks/TaskManager.cs

@@ -7,7 +7,6 @@ using System.Linq;
 using System.Threading.Tasks;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Model.Events;
-using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Tasks;
 using Microsoft.Extensions.Logging;
@@ -37,7 +36,6 @@ namespace Emby.Server.Implementations.ScheduledTasks
         private readonly IJsonSerializer _jsonSerializer;
         private readonly IApplicationPaths _applicationPaths;
         private readonly ILogger<TaskManager> _logger;
-        private readonly IFileSystem _fileSystem;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="TaskManager" /> class.
@@ -45,17 +43,14 @@ namespace Emby.Server.Implementations.ScheduledTasks
         /// <param name="applicationPaths">The application paths.</param>
         /// <param name="jsonSerializer">The json serializer.</param>
         /// <param name="logger">The logger.</param>
-        /// <param name="fileSystem">The filesystem manager.</param>
         public TaskManager(
             IApplicationPaths applicationPaths,
             IJsonSerializer jsonSerializer,
-            ILogger<TaskManager> logger,
-            IFileSystem fileSystem)
+            ILogger<TaskManager> logger)
         {
             _applicationPaths = applicationPaths;
             _jsonSerializer = jsonSerializer;
             _logger = logger;
-            _fileSystem = fileSystem;
 
             ScheduledTasks = Array.Empty<IScheduledTaskWorker>();
         }

+ 0 - 8
Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs

@@ -14,7 +14,6 @@ using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Tasks;
-using Microsoft.Extensions.Logging;
 using MediaBrowser.Model.Globalization;
 
 namespace Emby.Server.Implementations.ScheduledTasks
@@ -24,11 +23,6 @@ namespace Emby.Server.Implementations.ScheduledTasks
     /// </summary>
     public class ChapterImagesTask : IScheduledTask
     {
-        /// <summary>
-        /// The _logger.
-        /// </summary>
-        private readonly ILogger<ChapterImagesTask> _logger;
-
         /// <summary>
         /// The _library manager.
         /// </summary>
@@ -46,7 +40,6 @@ namespace Emby.Server.Implementations.ScheduledTasks
         /// Initializes a new instance of the <see cref="ChapterImagesTask" /> class.
         /// </summary>
         public ChapterImagesTask(
-            ILoggerFactory loggerFactory,
             ILibraryManager libraryManager,
             IItemRepository itemRepo,
             IApplicationPaths appPaths,
@@ -54,7 +47,6 @@ namespace Emby.Server.Implementations.ScheduledTasks
             IFileSystem fileSystem,
             ILocalizationManager localization)
         {
-            _logger = loggerFactory.CreateLogger<ChapterImagesTask>();
             _libraryManager = libraryManager;
             _itemRepo = itemRepo;
             _appPaths = appPaths;

+ 14 - 5
Emby.Server.Implementations/Services/ServiceController.cs

@@ -2,6 +2,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.Threading.Tasks;
 using Emby.Server.Implementations.HttpServer;
 using MediaBrowser.Model.Services;
@@ -91,12 +92,22 @@ namespace Emby.Server.Implementations.Services
         {
             if (restPath.Path[0] != '/')
             {
-                throw new ArgumentException(string.Format("Route '{0}' on '{1}' must start with a '/'", restPath.Path, restPath.RequestType.GetMethodName()));
+                throw new ArgumentException(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "Route '{0}' on '{1}' must start with a '/'",
+                        restPath.Path,
+                        restPath.RequestType.GetMethodName()));
             }
 
             if (restPath.Path.IndexOfAny(InvalidRouteChars) != -1)
             {
-                throw new ArgumentException(string.Format("Route '{0}' on '{1}' contains invalid chars. ", restPath.Path, restPath.RequestType.GetMethodName()));
+                throw new ArgumentException(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "Route '{0}' on '{1}' contains invalid chars. ",
+                        restPath.Path,
+                        restPath.RequestType.GetMethodName()));
             }
 
             if (RestPathMap.TryGetValue(restPath.FirstMatchHashKey, out List<RestPath> pathsAtFirstMatch))
@@ -179,8 +190,7 @@ namespace Emby.Server.Implementations.Services
 
             var service = httpHost.CreateInstance(serviceType);
 
-            var serviceRequiresContext = service as IRequiresRequest;
-            if (serviceRequiresContext != null)
+            if (service is IRequiresRequest serviceRequiresContext)
             {
                 serviceRequiresContext.Request = req;
             }
@@ -189,5 +199,4 @@ namespace Emby.Server.Implementations.Services
             return ServiceExecGeneral.Execute(serviceType, req, service, requestDto, requestType.GetMethodName());
         }
     }
-
 }

+ 8 - 1
Emby.Server.Implementations/Services/ServiceExec.cs

@@ -2,6 +2,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.Linq;
 using System.Linq.Expressions;
 using System.Reflection;
@@ -105,7 +106,13 @@ namespace Emby.Server.Implementations.Services
             }
 
             var expectedMethodName = actionName.Substring(0, 1) + actionName.Substring(1).ToLowerInvariant();
-            throw new NotImplementedException(string.Format("Could not find method named {1}({0}) or Any({0}) on Service {2}", requestDto.GetType().GetMethodName(), expectedMethodName, serviceType.GetMethodName()));
+            throw new NotImplementedException(
+                string.Format(
+                    CultureInfo.InvariantCulture,
+                    "Could not find method named {1}({0}) or Any({0}) on Service {2}",
+                    requestDto.GetType().GetMethodName(),
+                    expectedMethodName,
+                    serviceType.GetMethodName()));
         }
 
         private static async Task<object> GetTaskResult(Task task)

+ 16 - 10
Emby.Server.Implementations/Services/ServiceHandler.cs

@@ -2,10 +2,12 @@
 
 using System;
 using System.Collections.Generic;
+using System.Net.Mime;
 using System.Reflection;
 using System.Threading;
 using System.Threading.Tasks;
 using Emby.Server.Implementations.HttpServer;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Model.Services;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
@@ -44,7 +46,7 @@ namespace Emby.Server.Implementations.Services
             var pos = pathInfo.LastIndexOf('.');
             if (pos != -1)
             {
-                var format = pathInfo.Substring(pos + 1);
+                var format = pathInfo.AsSpan().Slice(pos + 1);
                 contentType = GetFormatContentType(format);
                 if (contentType != null)
                 {
@@ -55,18 +57,21 @@ namespace Emby.Server.Implementations.Services
             return pathInfo;
         }
 
-        private static string GetFormatContentType(string format)
+        private static string GetFormatContentType(ReadOnlySpan<char> format)
         {
-            // built-in formats
-            switch (format)
+            if (format.Equals("json", StringComparison.Ordinal))
             {
-                case "json": return "application/json";
-                case "xml": return "application/xml";
-                default: return null;
+                return MediaTypeNames.Application.Json;
             }
+            else if (format.Equals("xml", StringComparison.Ordinal))
+            {
+                return MediaTypeNames.Application.Xml;
+            }
+
+            return null;
         }
 
-        public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, ILogger logger, CancellationToken cancellationToken)
+        public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, CancellationToken cancellationToken)
         {
             httpReq.Items["__route"] = _restPath;
 
@@ -75,10 +80,11 @@ namespace Emby.Server.Implementations.Services
                 httpReq.ResponseContentType = _responseContentType;
             }
 
-            var request = await CreateRequest(httpHost, httpReq, _restPath, logger).ConfigureAwait(false);
+            var request = await CreateRequest(httpHost, httpReq, _restPath).ConfigureAwait(false);
 
             httpHost.ApplyRequestFilters(httpReq, httpRes, request);
 
+            httpRes.HttpContext.SetServiceStackRequest(httpReq);
             var response = await httpHost.ServiceController.Execute(httpHost, request, httpReq).ConfigureAwait(false);
 
             // Apply response filters
@@ -90,7 +96,7 @@ namespace Emby.Server.Implementations.Services
             await ResponseHelper.WriteToResponse(httpRes, httpReq, response, cancellationToken).ConfigureAwait(false);
         }
 
-        public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath, ILogger logger)
+        public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath)
         {
             var requestType = restPath.RequestType;
 

+ 5 - 3
Emby.Server.Implementations/Services/ServicePath.cs

@@ -156,7 +156,7 @@ namespace Emby.Server.Implementations.Services
             {
                 var component = components[i];
 
-                if (component.StartsWith(VariablePrefix))
+                if (component.StartsWith(VariablePrefix, StringComparison.Ordinal))
                 {
                     var variableName = component.Substring(1, component.Length - 2);
                     if (variableName[variableName.Length - 1] == WildCardChar)
@@ -488,7 +488,8 @@ namespace Emby.Server.Implementations.Services
                         sb.Append(value);
                         for (var j = pathIx + 1; j < requestComponents.Length; j++)
                         {
-                            sb.Append(PathSeperatorChar + requestComponents[j]);
+                            sb.Append(PathSeperatorChar)
+                                .Append(requestComponents[j]);
                         }
 
                         value = sb.ToString();
@@ -505,7 +506,8 @@ namespace Emby.Server.Implementations.Services
                             pathIx++;
                             while (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase))
                             {
-                                sb.Append(PathSeperatorChar + requestComponents[pathIx++]);
+                                sb.Append(PathSeperatorChar)
+                                    .Append(requestComponents[pathIx++]);
                             }
 
                             value = sb.ToString();

+ 2 - 2
Emby.Server.Implementations/Session/SessionManager.cs

@@ -848,8 +848,8 @@ namespace Emby.Server.Implementations.Session
         /// </summary>
         /// <param name="info">The info.</param>
         /// <returns>Task.</returns>
-        /// <exception cref="ArgumentNullException">info</exception>
-        /// <exception cref="ArgumentOutOfRangeException">positionTicks</exception>
+        /// <exception cref="ArgumentNullException"><c>info</c> is <c>null</c>.</exception>
+        /// <exception cref="ArgumentOutOfRangeException"><c>info.PositionTicks</c> is <c>null</c> or negative.</exception>
         public async Task OnPlaybackStopped(PlaybackStopInfo info)
         {
             CheckDisposed();

+ 16 - 14
Emby.Server.Implementations/Session/SessionWebSocketListener.cs

@@ -93,7 +93,7 @@ namespace Emby.Server.Implementations.Session
             if (session != null)
             {
                 EnsureController(session, e.Argument);
-                await KeepAliveWebSocket(e.Argument);
+                await KeepAliveWebSocket(e.Argument).ConfigureAwait(false);
             }
             else
             {
@@ -177,7 +177,7 @@ namespace Emby.Server.Implementations.Session
             // Notify WebSocket about timeout
             try
             {
-                await SendForceKeepAlive(webSocket);
+                await SendForceKeepAlive(webSocket).ConfigureAwait(false);
             }
             catch (WebSocketException exception)
             {
@@ -233,6 +233,7 @@ namespace Emby.Server.Implementations.Session
                 if (_keepAliveCancellationToken != null)
                 {
                     _keepAliveCancellationToken.Cancel();
+                    _keepAliveCancellationToken.Dispose();
                     _keepAliveCancellationToken = null;
                 }
             }
@@ -268,7 +269,7 @@ namespace Emby.Server.Implementations.Session
                 lost = _webSockets.Where(i => (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds >= WebSocketLostTimeout).ToList();
             }
 
-            if (inactive.Any())
+            if (inactive.Count > 0)
             {
                 _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
             }
@@ -277,7 +278,7 @@ namespace Emby.Server.Implementations.Session
             {
                 try
                 {
-                    await SendForceKeepAlive(webSocket);
+                    await SendForceKeepAlive(webSocket).ConfigureAwait(false);
                 }
                 catch (WebSocketException exception)
                 {
@@ -288,7 +289,7 @@ namespace Emby.Server.Implementations.Session
 
             lock (_webSocketsLock)
             {
-                if (lost.Any())
+                if (lost.Count > 0)
                 {
                     _logger.LogInformation("Lost {0} WebSockets.", lost.Count);
                     foreach (var webSocket in lost)
@@ -298,7 +299,7 @@ namespace Emby.Server.Implementations.Session
                     }
                 }
 
-                if (!_webSockets.Any())
+                if (_webSockets.Count == 0)
                 {
                     StopKeepAlive();
                 }
@@ -312,11 +313,13 @@ namespace Emby.Server.Implementations.Session
         /// <returns>Task.</returns>
         private Task SendForceKeepAlive(IWebSocketConnection webSocket)
         {
-            return webSocket.SendAsync(new WebSocketMessage<int>
-            {
-                MessageType = "ForceKeepAlive",
-                Data = WebSocketLostTimeout
-            }, CancellationToken.None);
+            return webSocket.SendAsync(
+                new WebSocketMessage<int>
+                {
+                    MessageType = "ForceKeepAlive",
+                    Data = WebSocketLostTimeout
+                },
+                CancellationToken.None);
         }
 
         /// <summary>
@@ -330,12 +333,11 @@ namespace Emby.Server.Implementations.Session
         {
             while (!cancellationToken.IsCancellationRequested)
             {
-                await callback();
-                Task task = Task.Delay(interval, cancellationToken);
+                await callback().ConfigureAwait(false);
 
                 try
                 {
-                    await task;
+                    await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
                 }
                 catch (TaskCanceledException)
                 {

+ 2 - 2
Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs

@@ -154,8 +154,8 @@ namespace Emby.Server.Implementations.Sorting
 
         private static int CompareEpisodes(Episode x, Episode y)
         {
-            var xValue = (x.ParentIndexNumber ?? -1) * 1000 + (x.IndexNumber ?? -1);
-            var yValue = (y.ParentIndexNumber ?? -1) * 1000 + (y.IndexNumber ?? -1);
+            var xValue = ((x.ParentIndexNumber ?? -1) * 1000) + (x.IndexNumber ?? -1);
+            var yValue = ((y.ParentIndexNumber ?? -1) * 1000) + (y.IndexNumber ?? -1);
 
             return xValue.CompareTo(yValue);
         }

+ 32 - 28
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
@@ -27,14 +28,17 @@ namespace Emby.Server.Implementations.SyncPlay
             /// All sessions will receive the message.
             /// </summary>
             AllGroup = 0,
+
             /// <summary>
             /// Only the specified session will receive the message.
             /// </summary>
             CurrentSession = 1,
+
             /// <summary>
             /// All sessions, except the current one, will receive the message.
             /// </summary>
             AllExceptCurrentSession = 2,
+
             /// <summary>
             /// Only sessions that are not buffering will receive the message.
             /// </summary>
@@ -56,15 +60,6 @@ namespace Emby.Server.Implementations.SyncPlay
         /// </summary>
         private readonly GroupInfo _group = new GroupInfo();
 
-        /// <inheritdoc />
-        public Guid GetGroupId() => _group.GroupId;
-
-        /// <inheritdoc />
-        public Guid GetPlayingItemId() => _group.PlayingItem.Id;
-
-        /// <inheritdoc />
-        public bool IsGroupEmpty() => _group.IsEmpty();
-
         /// <summary>
         /// Initializes a new instance of the <see cref="SyncPlayController" /> class.
         /// </summary>
@@ -78,6 +73,15 @@ namespace Emby.Server.Implementations.SyncPlay
             _syncPlayManager = syncPlayManager;
         }
 
+        /// <inheritdoc />
+        public Guid GetGroupId() => _group.GroupId;
+
+        /// <inheritdoc />
+        public Guid GetPlayingItemId() => _group.PlayingItem.Id;
+
+        /// <inheritdoc />
+        public bool IsGroupEmpty() => _group.IsEmpty();
+
         /// <summary>
         /// Converts DateTime to UTC string.
         /// </summary>
@@ -85,7 +89,7 @@ namespace Emby.Server.Implementations.SyncPlay
         /// <value>The UTC string.</value>
         private string DateToUTCString(DateTime date)
         {
-            return date.ToUniversalTime().ToString("o");
+            return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
         }
 
         /// <summary>
@@ -94,23 +98,23 @@ namespace Emby.Server.Implementations.SyncPlay
         /// <param name="from">The current session.</param>
         /// <param name="type">The filtering type.</param>
         /// <value>The array of sessions matching the filter.</value>
-        private SessionInfo[] FilterSessions(SessionInfo from, BroadcastType type)
+        private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type)
         {
             switch (type)
             {
                 case BroadcastType.CurrentSession:
                     return new SessionInfo[] { from };
                 case BroadcastType.AllGroup:
-                    return _group.Participants.Values.Select(
-                        session => session.Session).ToArray();
+                    return _group.Participants.Values
+                        .Select(session => session.Session);
                 case BroadcastType.AllExceptCurrentSession:
-                    return _group.Participants.Values.Select(
-                        session => session.Session).Where(
-                        session => !session.Id.Equals(from.Id)).ToArray();
+                    return _group.Participants.Values
+                        .Select(session => session.Session)
+                        .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal));
                 case BroadcastType.AllReady:
-                    return _group.Participants.Values.Where(
-                        session => !session.IsBuffering).Select(
-                        session => session.Session).ToArray();
+                    return _group.Participants.Values
+                        .Where(session => !session.IsBuffering)
+                        .Select(session => session.Session);
                 default:
                     return Array.Empty<SessionInfo>();
             }
@@ -128,10 +132,9 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             IEnumerable<Task> GetTasks()
             {
-                SessionInfo[] sessions = FilterSessions(from, type);
-                foreach (var session in sessions)
+                foreach (var session in FilterSessions(from, type))
                 {
-                    yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), message, cancellationToken);
+                    yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken);
                 }
             }
 
@@ -150,10 +153,9 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             IEnumerable<Task> GetTasks()
             {
-                SessionInfo[] sessions = FilterSessions(from, type);
-                foreach (var session in sessions)
+                foreach (var session in FilterSessions(from, type))
                 {
-                    yield return _sessionManager.SendSyncPlayCommand(session.Id.ToString(), message, cancellationToken);
+                    yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken);
                 }
             }
 
@@ -236,9 +238,11 @@ namespace Emby.Server.Implementations.SyncPlay
             }
             else
             {
-                var playRequest = new PlayRequest();
-                playRequest.ItemIds = new Guid[] { _group.PlayingItem.Id };
-                playRequest.StartPositionTicks = _group.PositionTicks;
+                var playRequest = new PlayRequest
+                {
+                    ItemIds = new Guid[] { _group.PlayingItem.Id },
+                    StartPositionTicks = _group.PositionTicks
+                };
                 var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest);
                 SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken);
             }

+ 3 - 5
Jellyfin.Api/Controllers/StartupController.cs

@@ -53,14 +53,12 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
         {
-            var result = new StartupConfigurationDto
+            return new StartupConfigurationDto
             {
                 UICulture = _config.Configuration.UICulture,
                 MetadataCountryCode = _config.Configuration.MetadataCountryCode,
                 PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage
             };
-
-            return result;
         }
 
         /// <summary>
@@ -110,10 +108,10 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("User")]
         [HttpGet("FirstUser")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<StartupUserDto> GetFirstUser()
+        public async Task<StartupUserDto> GetFirstUser()
         {
             // TODO: Remove this method when startup wizard no longer requires an existing user.
-            _userManager.Initialize();
+            await _userManager.InitializeAsync().ConfigureAwait(false);
             var user = _userManager.Users.First();
             return new StartupUserDto
             {

+ 150 - 0
Jellyfin.Data/Entities/DisplayPreferences.cs

@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Jellyfin.Data.Enums;
+
+namespace Jellyfin.Data.Entities
+{
+    /// <summary>
+    /// An entity representing a user's display preferences.
+    /// </summary>
+    public class DisplayPreferences
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DisplayPreferences"/> class.
+        /// </summary>
+        /// <param name="userId">The user's id.</param>
+        /// <param name="client">The client string.</param>
+        public DisplayPreferences(Guid userId, string client)
+        {
+            UserId = userId;
+            Client = client;
+            ShowSidebar = false;
+            ShowBackdrop = true;
+            SkipForwardLength = 30000;
+            SkipBackwardLength = 10000;
+            ScrollDirection = ScrollDirection.Horizontal;
+            ChromecastVersion = ChromecastVersion.Stable;
+            DashboardTheme = string.Empty;
+            TvHome = string.Empty;
+
+            HomeSections = new HashSet<HomeSection>();
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DisplayPreferences"/> class.
+        /// </summary>
+        protected DisplayPreferences()
+        {
+        }
+
+        /// <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 a value indicating whether to show the sidebar.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public bool ShowSidebar { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to show the backdrop.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public bool ShowBackdrop { get; set; }
+
+        /// <summary>
+        /// Gets or sets the scroll direction.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public ScrollDirection ScrollDirection { get; set; }
+
+        /// <summary>
+        /// Gets or sets what the view should be indexed by.
+        /// </summary>
+        public IndexingKind? IndexBy { get; set; }
+
+        /// <summary>
+        /// Gets or sets the length of time to skip forwards, in milliseconds.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public int SkipForwardLength { get; set; }
+
+        /// <summary>
+        /// Gets or sets the length of time to skip backwards, in milliseconds.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public int SkipBackwardLength { get; set; }
+
+        /// <summary>
+        /// Gets or sets the Chromecast Version.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public ChromecastVersion ChromecastVersion { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the next video info overlay should be shown.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public bool EnableNextVideoInfoOverlay { get; set; }
+
+        /// <summary>
+        /// Gets or sets the dashboard theme.
+        /// </summary>
+        [MaxLength(32)]
+        [StringLength(32)]
+        public string DashboardTheme { get; set; }
+
+        /// <summary>
+        /// Gets or sets the tv home screen.
+        /// </summary>
+        [MaxLength(32)]
+        [StringLength(32)]
+        public string TvHome { get; set; }
+
+        /// <summary>
+        /// Gets or sets the home sections.
+        /// </summary>
+        public virtual ICollection<HomeSection> HomeSections { get; protected set; }
+    }
+}

+ 46 - 0
Jellyfin.Data/Entities/HomeSection.cs

@@ -0,0 +1,46 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Jellyfin.Data.Enums;
+
+namespace Jellyfin.Data.Entities
+{
+    /// <summary>
+    /// An entity representing a section on the user's home page.
+    /// </summary>
+    public class HomeSection
+    {
+        /// <summary>
+        /// Gets or sets the id.
+        /// </summary>
+        /// <remarks>
+        /// Identity. Required.
+        /// </remarks>
+        [Key]
+        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+        public int Id { get; protected set; }
+
+        /// <summary>
+        /// Gets or sets the Id of the associated display preferences.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public int DisplayPreferencesId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the order.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public int Order { get; set; }
+
+        /// <summary>
+        /// Gets or sets the type.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public HomeSectionType Type { get; set; }
+    }
+}

+ 120 - 0
Jellyfin.Data/Entities/ItemDisplayPreferences.cs

@@ -0,0 +1,120 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Jellyfin.Data.Enums;
+
+namespace Jellyfin.Data.Entities
+{
+    public class ItemDisplayPreferences
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ItemDisplayPreferences"/> class.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="client">The client.</param>
+        public ItemDisplayPreferences(Guid userId, Guid itemId, string client)
+        {
+            UserId = userId;
+            ItemId = itemId;
+            Client = client;
+
+            SortBy = "SortName";
+            ViewType = ViewType.Poster;
+            SortOrder = SortOrder.Ascending;
+            RememberSorting = false;
+            RememberIndexing = false;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ItemDisplayPreferences"/> class.
+        /// </summary>
+        protected ItemDisplayPreferences()
+        {
+        }
+
+        /// <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 id of the associated item.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public Guid ItemId { 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 view type.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public ViewType ViewType { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the indexing should be remembered.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public bool RememberIndexing { get; set; }
+
+        /// <summary>
+        /// Gets or sets what the view should be indexed by.
+        /// </summary>
+        public IndexingKind? IndexBy { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the sorting type should be remembered.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public bool RememberSorting { get; set; }
+
+        /// <summary>
+        /// Gets or sets what the view should be sorted by.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        [MaxLength(64)]
+        [StringLength(64)]
+        public string SortBy { get; set; }
+
+        /// <summary>
+        /// Gets or sets the sort order.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        public SortOrder SortOrder { get; set; }
+    }
+}

+ 15 - 0
Jellyfin.Data/Entities/User.cs

@@ -48,6 +48,7 @@ namespace Jellyfin.Data.Entities
             PasswordResetProviderId = passwordResetProviderId;
 
             AccessSchedules = new HashSet<AccessSchedule>();
+            ItemDisplayPreferences = new HashSet<ItemDisplayPreferences>();
             // Groups = new HashSet<Group>();
             Permissions = new HashSet<Permission>();
             Preferences = new HashSet<Preference>();
@@ -327,6 +328,15 @@ namespace Jellyfin.Data.Entities
         // [ForeignKey("UserId")]
         public virtual ImageInfo ProfileImage { get; set; }
 
+        /// <summary>
+        /// Gets or sets the user's display preferences.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public virtual DisplayPreferences DisplayPreferences { get; set; }
+
         [Required]
         public SyncPlayAccess SyncPlayAccess { get; set; }
 
@@ -349,6 +359,11 @@ namespace Jellyfin.Data.Entities
         /// </summary>
         public virtual ICollection<AccessSchedule> AccessSchedules { get; protected set; }
 
+        /// <summary>
+        /// Gets or sets the list of item display preferences.
+        /// </summary>
+        public virtual ICollection<ItemDisplayPreferences> ItemDisplayPreferences { get; protected set; }
+
         /*
         /// <summary>
         /// Gets or sets the list of groups this user is a member of.

+ 18 - 0
Jellyfin.Data/Enums/ChromecastVersion.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Data.Enums
+{
+    /// <summary>
+    /// An enum representing the version of Chromecast to be used by clients.
+    /// </summary>
+    public enum ChromecastVersion
+    {
+        /// <summary>
+        /// Stable Chromecast version.
+        /// </summary>
+        Stable = 0,
+
+        /// <summary>
+        /// Unstable Chromecast version.
+        /// </summary>
+        Unstable = 1
+    }
+}

+ 53 - 0
Jellyfin.Data/Enums/HomeSectionType.cs

@@ -0,0 +1,53 @@
+namespace Jellyfin.Data.Enums
+{
+    /// <summary>
+    /// An enum representing the different options for the home screen sections.
+    /// </summary>
+    public enum HomeSectionType
+    {
+        /// <summary>
+        /// None.
+        /// </summary>
+        None = 0,
+
+        /// <summary>
+        /// My Media.
+        /// </summary>
+        SmallLibraryTiles = 1,
+
+        /// <summary>
+        /// My Media Small.
+        /// </summary>
+        LibraryButtons = 2,
+
+        /// <summary>
+        /// Active Recordings.
+        /// </summary>
+        ActiveRecordings = 3,
+
+        /// <summary>
+        /// Continue Watching.
+        /// </summary>
+        Resume = 4,
+
+        /// <summary>
+        /// Continue Listening.
+        /// </summary>
+        ResumeAudio = 5,
+
+        /// <summary>
+        /// Latest Media.
+        /// </summary>
+        LatestMedia = 6,
+
+        /// <summary>
+        /// Next Up.
+        /// </summary>
+        NextUp = 7,
+
+        /// <summary>
+        /// Live TV.
+        /// </summary>
+        LiveTv = 8
+    }
+}

+ 20 - 0
Jellyfin.Data/Enums/IndexingKind.cs

@@ -0,0 +1,20 @@
+namespace Jellyfin.Data.Enums
+{
+    public enum IndexingKind
+    {
+        /// <summary>
+        /// Index by the premiere date.
+        /// </summary>
+        PremiereDate = 0,
+
+        /// <summary>
+        /// Index by the production year.
+        /// </summary>
+        ProductionYear = 1,
+
+        /// <summary>
+        /// Index by the community rating.
+        /// </summary>
+        CommunityRating = 2
+    }
+}

+ 18 - 0
Jellyfin.Data/Enums/ScrollDirection.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Data.Enums
+{
+    /// <summary>
+    /// An enum representing the axis that should be scrolled.
+    /// </summary>
+    public enum ScrollDirection
+    {
+        /// <summary>
+        /// Horizontal scrolling direction.
+        /// </summary>
+        Horizontal = 0,
+
+        /// <summary>
+        /// Vertical scrolling direction.
+        /// </summary>
+        Vertical = 1
+    }
+}

+ 18 - 0
Jellyfin.Data/Enums/SortOrder.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Data.Enums
+{
+    /// <summary>
+    /// An enum representing the sorting order.
+    /// </summary>
+    public enum SortOrder
+    {
+        /// <summary>
+        /// Sort in increasing order.
+        /// </summary>
+        Ascending = 0,
+
+        /// <summary>
+        /// Sort in decreasing order.
+        /// </summary>
+        Descending = 1
+    }
+}

+ 38 - 0
Jellyfin.Data/Enums/ViewType.cs

@@ -0,0 +1,38 @@
+namespace Jellyfin.Data.Enums
+{
+    /// <summary>
+    /// An enum representing the type of view for a library or collection.
+    /// </summary>
+    public enum ViewType
+    {
+        /// <summary>
+        /// Shows banners.
+        /// </summary>
+        Banner = 0,
+
+        /// <summary>
+        /// Shows a list of content.
+        /// </summary>
+        List = 1,
+
+        /// <summary>
+        /// Shows poster artwork.
+        /// </summary>
+        Poster = 2,
+
+        /// <summary>
+        /// Shows poster artwork with a card containing the name and year.
+        /// </summary>
+        PosterCard = 3,
+
+        /// <summary>
+        /// Shows a thumbnail.
+        /// </summary>
+        Thumb = 4,
+
+        /// <summary>
+        /// Shows a thumbnail with a card containing the name and year.
+        /// </summary>
+        ThumbCard = 5
+    }
+}

+ 4 - 5
Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj

@@ -18,11 +18,10 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="BlurHashSharp" Version="1.0.1" />
-    <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.0.0" />
-    <PackageReference Include="SkiaSharp" Version="1.68.3" />
-    <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="1.68.3" />
-    <PackageReference Include="Jellyfin.SkiaSharp.NativeAssets.LinuxArm" Version="1.68.1" />
+    <PackageReference Include="BlurHashSharp" Version="1.1.0" />
+    <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.1.0" />
+    <PackageReference Include="SkiaSharp" Version="2.80.1" />
+    <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.80.1" />
   </ItemGroup>
 
   <ItemGroup>

+ 9 - 13
Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs

@@ -19,22 +19,18 @@ namespace Jellyfin.Drawing.Skia
         /// <param name="percent">The percentage played to display with the indicator.</param>
         public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent)
         {
-            using (var paint = new SKPaint())
-            {
-                var endX = imageSize.Width - 1;
-                var endY = imageSize.Height - 1;
+            using var paint = new SKPaint();
+            var endX = imageSize.Width - 1;
+            var endY = imageSize.Height - 1;
 
-                paint.Color = SKColor.Parse("#99000000");
-                paint.Style = SKPaintStyle.Fill;
-                canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, (float)endX, (float)endY), paint);
+            paint.Color = SKColor.Parse("#99000000");
+            paint.Style = SKPaintStyle.Fill;
+            canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint);
 
-                double foregroundWidth = endX;
-                foregroundWidth *= percent;
-                foregroundWidth /= 100;
+            double foregroundWidth = (endX * percent) / 100;
 
-                paint.Color = SKColor.Parse("#FF00A4DC");
-                canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), (float)endY), paint);
-            }
+            paint.Color = SKColor.Parse("#FF00A4DC");
+            canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint);
         }
     }
 }

+ 15 - 19
Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs

@@ -22,31 +22,27 @@ namespace Jellyfin.Drawing.Skia
         {
             var x = imageSize.Width - OffsetFromTopRightCorner;
 
-            using (var paint = new SKPaint())
+            using var paint = new SKPaint
             {
-                paint.Color = SKColor.Parse("#CC00A4DC");
-                paint.Style = SKPaintStyle.Fill;
-                canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
-            }
+                Color = SKColor.Parse("#CC00A4DC"),
+                Style = SKPaintStyle.Fill
+            };
 
-            using (var paint = new SKPaint())
-            {
-                paint.Color = new SKColor(255, 255, 255, 255);
-                paint.Style = SKPaintStyle.Fill;
+            canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
 
-                paint.TextSize = 30;
-                paint.IsAntialias = true;
+            paint.Color = new SKColor(255, 255, 255, 255);
+            paint.TextSize = 30;
+            paint.IsAntialias = true;
 
-                // or:
-                // var emojiChar = 0x1F680;
-                const string Text = "✔️";
-                var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
+            // or:
+            // var emojiChar = 0x1F680;
+            const string Text = "✔️";
+            var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
 
-                // ask the font manager for a font with that character
-                paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
+            // ask the font manager for a font with that character
+            paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
 
-                canvas.DrawText(Text, (float)x - 20, OffsetFromTopRightCorner + 12, paint);
-            }
+            canvas.DrawText(Text, (float)x - 20, OffsetFromTopRightCorner + 12, paint);
         }
     }
 }

+ 1 - 1
Jellyfin.Drawing.Skia/SkiaCodecException.cs

@@ -12,7 +12,7 @@ namespace Jellyfin.Drawing.Skia
         /// Initializes a new instance of the <see cref="SkiaCodecException" /> class.
         /// </summary>
         /// <param name="result">The non-successful codec result returned by Skia.</param>
-        public SkiaCodecException(SKCodecResult result) : base()
+        public SkiaCodecException(SKCodecResult result)
         {
             CodecResult = result;
         }

+ 214 - 311
Jellyfin.Drawing.Skia/SkiaEncoder.cs

@@ -29,9 +29,7 @@ namespace Jellyfin.Drawing.Skia
         /// </summary>
         /// <param name="logger">The application logger.</param>
         /// <param name="appPaths">The application paths.</param>
-        public SkiaEncoder(
-            ILogger<SkiaEncoder> logger,
-            IApplicationPaths appPaths)
+        public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
         {
             _logger = logger;
             _appPaths = appPaths;
@@ -102,19 +100,14 @@ namespace Jellyfin.Drawing.Skia
         /// <returns>The converted format.</returns>
         public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
         {
-            switch (selectedFormat)
-            {
-                case ImageFormat.Bmp:
-                    return SKEncodedImageFormat.Bmp;
-                case ImageFormat.Jpg:
-                    return SKEncodedImageFormat.Jpeg;
-                case ImageFormat.Gif:
-                    return SKEncodedImageFormat.Gif;
-                case ImageFormat.Webp:
-                    return SKEncodedImageFormat.Webp;
-                default:
-                    return SKEncodedImageFormat.Png;
-            }
+            return selectedFormat switch
+            {
+                ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
+                ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
+                ImageFormat.Gif => SKEncodedImageFormat.Gif,
+                ImageFormat.Webp => SKEncodedImageFormat.Webp,
+                _ => SKEncodedImageFormat.Png
+            };
         }
 
         private static bool IsTransparentRow(SKBitmap bmp, int row)
@@ -146,63 +139,34 @@ namespace Jellyfin.Drawing.Skia
         private SKBitmap CropWhiteSpace(SKBitmap bitmap)
         {
             var topmost = 0;
-            for (int row = 0; row < bitmap.Height; ++row)
+            while (topmost < bitmap.Height && IsTransparentRow(bitmap, topmost))
             {
-                if (IsTransparentRow(bitmap, row))
-                {
-                    topmost = row + 1;
-                }
-                else
-                {
-                    break;
-                }
+                topmost++;
             }
 
             int bottommost = bitmap.Height;
-            for (int row = bitmap.Height - 1; row >= 0; --row)
+            while (bottommost >= 0 && IsTransparentRow(bitmap, bottommost - 1))
             {
-                if (IsTransparentRow(bitmap, row))
-                {
-                    bottommost = row;
-                }
-                else
-                {
-                    break;
-                }
+                bottommost--;
             }
 
-            int leftmost = 0, rightmost = bitmap.Width;
-            for (int col = 0; col < bitmap.Width; ++col)
+            var leftmost = 0;
+            while (leftmost < bitmap.Width && IsTransparentColumn(bitmap, leftmost))
             {
-                if (IsTransparentColumn(bitmap, col))
-                {
-                    leftmost = col + 1;
-                }
-                else
-                {
-                    break;
-                }
+                leftmost++;
             }
 
-            for (int col = bitmap.Width - 1; col >= 0; --col)
+            var rightmost = bitmap.Width;
+            while (rightmost >= 0 && IsTransparentColumn(bitmap, rightmost - 1))
             {
-                if (IsTransparentColumn(bitmap, col))
-                {
-                    rightmost = col;
-                }
-                else
-                {
-                    break;
-                }
+                rightmost--;
             }
 
             var newRect = SKRectI.Create(leftmost, topmost, rightmost - leftmost, bottommost - topmost);
 
-            using (var image = SKImage.FromBitmap(bitmap))
-            using (var subset = image.Subset(newRect))
-            {
-                return SKBitmap.FromImage(subset);
-            }
+            using var image = SKImage.FromBitmap(bitmap);
+            using var subset = image.Subset(newRect);
+            return SKBitmap.FromImage(subset);
         }
 
         /// <inheritdoc />
@@ -216,14 +180,12 @@ namespace Jellyfin.Drawing.Skia
                 throw new FileNotFoundException("File not found", path);
             }
 
-            using (var codec = SKCodec.Create(path, out SKCodecResult result))
-            {
-                EnsureSuccess(result);
+            using var codec = SKCodec.Create(path, out SKCodecResult result);
+            EnsureSuccess(result);
 
-                var info = codec.Info;
+            var info = codec.Info;
 
-                return new ImageDimensions(info.Width, info.Height);
-            }
+            return new ImageDimensions(info.Width, info.Height);
         }
 
         /// <inheritdoc />
@@ -237,7 +199,8 @@ namespace Jellyfin.Drawing.Skia
                 throw new ArgumentNullException(nameof(path));
             }
 
-            return BlurHashEncoder.Encode(xComp, yComp, path);
+            // Any larger than 128x128 is too slow and there's no visually discernible difference
+            return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128);
         }
 
         private static bool HasDiacritics(string text)
@@ -253,12 +216,7 @@ namespace Jellyfin.Drawing.Skia
                 }
             }
 
-            if (HasDiacritics(path))
-            {
-                return true;
-            }
-
-            return false;
+            return HasDiacritics(path);
         }
 
         private string NormalizePath(string path)
@@ -283,25 +241,17 @@ namespace Jellyfin.Drawing.Skia
                 return SKEncodedOrigin.TopLeft;
             }
 
-            switch (orientation.Value)
-            {
-                case ImageOrientation.TopRight:
-                    return SKEncodedOrigin.TopRight;
-                case ImageOrientation.RightTop:
-                    return SKEncodedOrigin.RightTop;
-                case ImageOrientation.RightBottom:
-                    return SKEncodedOrigin.RightBottom;
-                case ImageOrientation.LeftTop:
-                    return SKEncodedOrigin.LeftTop;
-                case ImageOrientation.LeftBottom:
-                    return SKEncodedOrigin.LeftBottom;
-                case ImageOrientation.BottomRight:
-                    return SKEncodedOrigin.BottomRight;
-                case ImageOrientation.BottomLeft:
-                    return SKEncodedOrigin.BottomLeft;
-                default:
-                    return SKEncodedOrigin.TopLeft;
-            }
+            return orientation.Value switch
+            {
+                ImageOrientation.TopRight => SKEncodedOrigin.TopRight,
+                ImageOrientation.RightTop => SKEncodedOrigin.RightTop,
+                ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom,
+                ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop,
+                ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom,
+                ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight,
+                ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft,
+                _ => SKEncodedOrigin.TopLeft
+            };
         }
 
         /// <summary>
@@ -323,24 +273,22 @@ namespace Jellyfin.Drawing.Skia
 
             if (requiresTransparencyHack || forceCleanBitmap)
             {
-                using (var codec = SKCodec.Create(NormalizePath(path)))
+                using var codec = SKCodec.Create(NormalizePath(path));
+                if (codec == null)
                 {
-                    if (codec == null)
-                    {
-                        origin = GetSKEncodedOrigin(orientation);
-                        return null;
-                    }
+                    origin = GetSKEncodedOrigin(orientation);
+                    return null;
+                }
 
-                    // create the bitmap
-                    var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
+                // create the bitmap
+                var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
 
-                    // decode
-                    _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
+                // decode
+                _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
 
-                    origin = codec.EncodedOrigin;
+                origin = codec.EncodedOrigin;
 
-                    return bitmap;
-                }
+                return bitmap;
             }
 
             var resultBitmap = SKBitmap.Decode(NormalizePath(path));
@@ -367,15 +315,8 @@ namespace Jellyfin.Drawing.Skia
         {
             if (cropWhitespace)
             {
-                using (var bitmap = Decode(path, forceAnalyzeBitmap, orientation, out origin))
-                {
-                    if (bitmap == null)
-                    {
-                        return null;
-                    }
-
-                    return CropWhiteSpace(bitmap);
-                }
+                using var bitmap = Decode(path, forceAnalyzeBitmap, orientation, out origin);
+                return bitmap == null ? null : CropWhiteSpace(bitmap);
             }
 
             return Decode(path, forceAnalyzeBitmap, orientation, out origin);
@@ -403,133 +344,105 @@ namespace Jellyfin.Drawing.Skia
 
         private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
         {
+            if (origin == SKEncodedOrigin.Default)
+            {
+                return bitmap;
+            }
+
+            var needsFlip = origin == SKEncodedOrigin.LeftBottom
+                            || origin == SKEncodedOrigin.LeftTop
+                            || origin == SKEncodedOrigin.RightBottom
+                            || origin == SKEncodedOrigin.RightTop;
+            var rotated = needsFlip
+                ? new SKBitmap(bitmap.Height, bitmap.Width)
+                : new SKBitmap(bitmap.Width, bitmap.Height);
+            using var surface = new SKCanvas(rotated);
+            var midX = (float)rotated.Width / 2;
+            var midY = (float)rotated.Height / 2;
+
             switch (origin)
             {
                 case SKEncodedOrigin.TopRight:
-                    {
-                        var rotated = new SKBitmap(bitmap.Width, bitmap.Height);
-                        using (var surface = new SKCanvas(rotated))
-                        {
-                            surface.Translate(rotated.Width, 0);
-                            surface.Scale(-1, 1);
-                            surface.DrawBitmap(bitmap, 0, 0);
-                        }
-
-                        return rotated;
-                    }
-
+                    surface.Scale(-1, 1, midX, midY);
+                    break;
                 case SKEncodedOrigin.BottomRight:
-                    {
-                        var rotated = new SKBitmap(bitmap.Width, bitmap.Height);
-                        using (var surface = new SKCanvas(rotated))
-                        {
-                            float px = (float)bitmap.Width / 2;
-                            float py = (float)bitmap.Height / 2;
-
-                            surface.RotateDegrees(180, px, py);
-                            surface.DrawBitmap(bitmap, 0, 0);
-                        }
-
-                        return rotated;
-                    }
-
+                    surface.RotateDegrees(180, midX, midY);
+                    break;
                 case SKEncodedOrigin.BottomLeft:
-                    {
-                        var rotated = new SKBitmap(bitmap.Width, bitmap.Height);
-                        using (var surface = new SKCanvas(rotated))
-                        {
-                            float px = (float)bitmap.Width / 2;
-
-                            float py = (float)bitmap.Height / 2;
-
-                            surface.Translate(rotated.Width, 0);
-                            surface.Scale(-1, 1);
-
-                            surface.RotateDegrees(180, px, py);
-                            surface.DrawBitmap(bitmap, 0, 0);
-                        }
-
-                        return rotated;
-                    }
-
+                    surface.Scale(1, -1, midX, midY);
+                    break;
                 case SKEncodedOrigin.LeftTop:
-                    {
-                        // TODO: Remove dual canvases, had trouble with flipping
-                        using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width))
-                        {
-                            using (var surface = new SKCanvas(rotated))
-                            {
-                                surface.Translate(rotated.Width, 0);
-
-                                surface.RotateDegrees(90);
-
-                                surface.DrawBitmap(bitmap, 0, 0);
-                            }
-
-                            var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height);
-                            using (var flippedCanvas = new SKCanvas(flippedBitmap))
-                            {
-                                flippedCanvas.Translate(flippedBitmap.Width, 0);
-                                flippedCanvas.Scale(-1, 1);
-                                flippedCanvas.DrawBitmap(rotated, 0, 0);
-                            }
-
-                            return flippedBitmap;
-                        }
-                    }
-
+                    surface.Translate(0, -rotated.Height);
+                    surface.Scale(1, -1, midX, midY);
+                    surface.RotateDegrees(-90);
+                    break;
                 case SKEncodedOrigin.RightTop:
-                    {
-                        var rotated = new SKBitmap(bitmap.Height, bitmap.Width);
-                        using (var surface = new SKCanvas(rotated))
-                        {
-                            surface.Translate(rotated.Width, 0);
-                            surface.RotateDegrees(90);
-                            surface.DrawBitmap(bitmap, 0, 0);
-                        }
-
-                        return rotated;
-                    }
-
+                    surface.Translate(rotated.Width, 0);
+                    surface.RotateDegrees(90);
+                    break;
                 case SKEncodedOrigin.RightBottom:
-                    {
-                        // TODO: Remove dual canvases, had trouble with flipping
-                        using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width))
-                        {
-                            using (var surface = new SKCanvas(rotated))
-                            {
-                                surface.Translate(0, rotated.Height);
-                                surface.RotateDegrees(270);
-                                surface.DrawBitmap(bitmap, 0, 0);
-                            }
-
-                            var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height);
-                            using (var flippedCanvas = new SKCanvas(flippedBitmap))
-                            {
-                                flippedCanvas.Translate(flippedBitmap.Width, 0);
-                                flippedCanvas.Scale(-1, 1);
-                                flippedCanvas.DrawBitmap(rotated, 0, 0);
-                            }
-
-                            return flippedBitmap;
-                        }
-                    }
-
+                    surface.Translate(rotated.Width, 0);
+                    surface.Scale(1, -1, midX, midY);
+                    surface.RotateDegrees(90);
+                    break;
                 case SKEncodedOrigin.LeftBottom:
-                    {
-                        var rotated = new SKBitmap(bitmap.Height, bitmap.Width);
-                        using (var surface = new SKCanvas(rotated))
-                        {
-                            surface.Translate(0, rotated.Height);
-                            surface.RotateDegrees(270);
-                            surface.DrawBitmap(bitmap, 0, 0);
-                        }
-
-                        return rotated;
-                    }
-
-                default: return bitmap;
+                    surface.Translate(0, rotated.Height);
+                    surface.RotateDegrees(-90);
+                    break;
             }
+
+            surface.DrawBitmap(bitmap, 0, 0);
+            return rotated;
+        }
+
+        /// <summary>
+        /// Resizes an image on the CPU, by utilizing a surface and canvas.
+        ///
+        /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
+        /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html).
+        /// </summary>
+        /// <param name="source">The source bitmap.</param>
+        /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param>
+        /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
+        /// <param name="isDither">This enables dithering on the SKPaint instance.</param>
+        /// <returns>The resized image.</returns>
+        internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
+        {
+            using var surface = SKSurface.Create(targetInfo);
+            using var canvas = surface.Canvas;
+            using var paint = new SKPaint
+            {
+                FilterQuality = SKFilterQuality.High,
+                IsAntialias = isAntialias,
+                IsDither = isDither
+            };
+
+            var kernel = new float[9]
+            {
+                0,    -.1f,    0,
+                -.1f, 1.4f, -.1f,
+                0,    -.1f,    0,
+            };
+
+            var kernelSize = new SKSizeI(3, 3);
+            var kernelOffset = new SKPointI(1, 1);
+
+            paint.ImageFilter = SKImageFilter.CreateMatrixConvolution(
+                kernelSize,
+                kernel,
+                1f,
+                0f,
+                kernelOffset,
+                SKShaderTileMode.Clamp,
+                false);
+
+            canvas.DrawBitmap(
+                source,
+                SKRect.Create(0, 0, source.Width, source.Height),
+                SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
+                paint);
+
+            return surface.Snapshot();
         }
 
         /// <inheritdoc/>
@@ -552,97 +465,87 @@ namespace Jellyfin.Drawing.Skia
             var blur = options.Blur ?? 0;
             var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
 
-            using (var bitmap = GetBitmap(inputPath, options.CropWhiteSpace, autoOrient, orientation))
+            using var bitmap = GetBitmap(inputPath, options.CropWhiteSpace, autoOrient, orientation);
+            if (bitmap == null)
             {
-                if (bitmap == null)
-                {
-                    throw new InvalidDataException($"Skia unable to read image {inputPath}");
-                }
+                throw new InvalidDataException($"Skia unable to read image {inputPath}");
+            }
 
-                var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
+            var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
 
-                if (!options.CropWhiteSpace
-                    && options.HasDefaultOptions(inputPath, originalImageSize)
-                    && !autoOrient)
-                {
-                    // Just spit out the original file if all the options are default
-                    return inputPath;
-                }
+            if (!options.CropWhiteSpace
+                && options.HasDefaultOptions(inputPath, originalImageSize)
+                && !autoOrient)
+            {
+                // Just spit out the original file if all the options are default
+                return inputPath;
+            }
+
+            var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
+
+            var width = newImageSize.Width;
+            var height = newImageSize.Height;
 
-                var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
+            // scale image (the FromImage creates a copy)
+            var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
+            using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo));
 
-                var width = newImageSize.Width;
-                var height = newImageSize.Height;
+            // If all we're doing is resizing then we can stop now
+            if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
+            {
+                Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
+                using var outputStream = new SKFileWStream(outputPath);
+                using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
+                resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
+                return outputPath;
+            }
+
+            // create bitmap to use for canvas drawing used to draw into bitmap
+            using var saveBitmap = new SKBitmap(width, height);
+            using var canvas = new SKCanvas(saveBitmap);
+            // set background color if present
+            if (hasBackgroundColor)
+            {
+                canvas.Clear(SKColor.Parse(options.BackgroundColor));
+            }
 
-                using (var resizedBitmap = new SKBitmap(width, height, bitmap.ColorType, bitmap.AlphaType))
+            // Add blur if option is present
+            if (blur > 0)
+            {
+                // create image from resized bitmap to apply blur
+                using var paint = new SKPaint();
+                using var filter = SKImageFilter.CreateBlur(blur, blur);
+                paint.ImageFilter = filter;
+                canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint);
+            }
+            else
+            {
+                // draw resized bitmap onto canvas
+                canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height));
+            }
+
+            // If foreground layer present then draw
+            if (hasForegroundColor)
+            {
+                if (!double.TryParse(options.ForegroundLayer, out double opacity))
                 {
-                    // scale image
-                    bitmap.ScalePixels(resizedBitmap, SKFilterQuality.High);
+                    opacity = .4;
+                }
 
-                    // If all we're doing is resizing then we can stop now
-                    if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
-                    {
-                        Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
-                        using (var outputStream = new SKFileWStream(outputPath))
-                        using (var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels()))
-                        {
-                            pixmap.Encode(outputStream, skiaOutputFormat, quality);
-                            return outputPath;
-                        }
-                    }
+                canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
+            }
 
-                    // create bitmap to use for canvas drawing used to draw into bitmap
-                    using (var saveBitmap = new SKBitmap(width, height)) // , bitmap.ColorType, bitmap.AlphaType))
-                    using (var canvas = new SKCanvas(saveBitmap))
-                    {
-                        // set background color if present
-                        if (hasBackgroundColor)
-                        {
-                            canvas.Clear(SKColor.Parse(options.BackgroundColor));
-                        }
-
-                        // Add blur if option is present
-                        if (blur > 0)
-                        {
-                            // create image from resized bitmap to apply blur
-                            using (var paint = new SKPaint())
-                            using (var filter = SKImageFilter.CreateBlur(blur, blur))
-                            {
-                                paint.ImageFilter = filter;
-                                canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint);
-                            }
-                        }
-                        else
-                        {
-                            // draw resized bitmap onto canvas
-                            canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height));
-                        }
-
-                        // If foreground layer present then draw
-                        if (hasForegroundColor)
-                        {
-                            if (!double.TryParse(options.ForegroundLayer, out double opacity))
-                            {
-                                opacity = .4;
-                            }
-
-                            canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
-                        }
-
-                        if (hasIndicator)
-                        {
-                            DrawIndicator(canvas, width, height, options);
-                        }
-
-                        Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
-                        using (var outputStream = new SKFileWStream(outputPath))
-                        {
-                            using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
-                            {
-                                pixmap.Encode(outputStream, skiaOutputFormat, quality);
-                            }
-                        }
-                    }
+            if (hasIndicator)
+            {
+                DrawIndicator(canvas, width, height, options);
+            }
+
+            Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
+            using (var outputStream = new SKFileWStream(outputPath))
+            {
+                using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
+                {
+                    pixmap.Encode(outputStream, skiaOutputFormat, quality);
                 }
             }
 

+ 1 - 1
Jellyfin.Drawing.Skia/SkiaException.cs

@@ -10,7 +10,7 @@ namespace Jellyfin.Drawing.Skia
         /// <summary>
         /// Initializes a new instance of the <see cref="SkiaException"/> class.
         /// </summary>
-        public SkiaException() : base()
+        public SkiaException()
         {
         }
 

+ 49 - 69
Jellyfin.Drawing.Skia/StripCollageBuilder.cs

@@ -69,12 +69,10 @@ namespace Jellyfin.Drawing.Skia
         /// <param name="height">The desired height of the collage.</param>
         public void BuildSquareCollage(string[] paths, string outputPath, int width, int height)
         {
-            using (var bitmap = BuildSquareCollageBitmap(paths, width, height))
-            using (var outputStream = new SKFileWStream(outputPath))
-            using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()))
-            {
-                pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
-            }
+            using var bitmap = BuildSquareCollageBitmap(paths, width, height);
+            using var outputStream = new SKFileWStream(outputPath);
+            using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
+            pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
         }
 
         /// <summary>
@@ -86,56 +84,44 @@ namespace Jellyfin.Drawing.Skia
         /// <param name="height">The desired height of the collage.</param>
         public void BuildThumbCollage(string[] paths, string outputPath, int width, int height)
         {
-            using (var bitmap = BuildThumbCollageBitmap(paths, width, height))
-            using (var outputStream = new SKFileWStream(outputPath))
-            using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()))
-            {
-                pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
-            }
+            using var bitmap = BuildThumbCollageBitmap(paths, width, height);
+            using var outputStream = new SKFileWStream(outputPath);
+            using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
+            pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
         }
 
         private SKBitmap BuildThumbCollageBitmap(string[] paths, int width, int height)
         {
             var bitmap = new SKBitmap(width, height);
 
-            using (var canvas = new SKCanvas(bitmap))
-            {
-                canvas.Clear(SKColors.Black);
+            using var canvas = new SKCanvas(bitmap);
+            canvas.Clear(SKColors.Black);
 
-                // number of images used in the thumbnail
-                var iCount = 3;
+            // number of images used in the thumbnail
+            var iCount = 3;
 
-                // determine sizes for each image that will composited into the final image
-                var iSlice = Convert.ToInt32(width / iCount);
-                int iHeight = Convert.ToInt32(height * 1.00);
-                int imageIndex = 0;
-                for (int i = 0; i < iCount; i++)
+            // determine sizes for each image that will composited into the final image
+            var iSlice = Convert.ToInt32(width / iCount);
+            int iHeight = Convert.ToInt32(height * 1.00);
+            int imageIndex = 0;
+            for (int i = 0; i < iCount; i++)
+            {
+                using var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex);
+                imageIndex = newIndex;
+                if (currentBitmap == null)
                 {
-                    using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex))
-                    {
-                        imageIndex = newIndex;
-                        if (currentBitmap == null)
-                        {
-                            continue;
-                        }
-
-                        // resize to the same aspect as the original
-                        int iWidth = Math.Abs(iHeight * currentBitmap.Width / currentBitmap.Height);
-                        using (var resizeBitmap = new SKBitmap(iWidth, iHeight, currentBitmap.ColorType, currentBitmap.AlphaType))
-                        {
-                            currentBitmap.ScalePixels(resizeBitmap, SKFilterQuality.High);
-
-                            // crop image
-                            int ix = Math.Abs((iWidth - iSlice) / 2);
-                            using (var image = SKImage.FromBitmap(resizeBitmap))
-                            using (var subset = image.Subset(SKRectI.Create(ix, 0, iSlice, iHeight)))
-                            {
-                                // draw image onto canvas
-                                canvas.DrawImage(subset ?? image, iSlice * i, 0);
-                            }
-                        }
-                    }
+                    continue;
                 }
+
+                // resize to the same aspect as the original
+                int iWidth = Math.Abs(iHeight * currentBitmap.Width / currentBitmap.Height);
+                using var resizedImage = SkiaEncoder.ResizeImage(bitmap, new SKImageInfo(iWidth, iHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace));
+
+                // crop image
+                int ix = Math.Abs((iWidth - iSlice) / 2);
+                using var subset = resizedImage.Subset(SKRectI.Create(ix, 0, iSlice, iHeight));
+                // draw image onto canvas
+                canvas.DrawImage(subset ?? resizedImage, iSlice * i, 0);
             }
 
             return bitmap;
@@ -176,33 +162,27 @@ namespace Jellyfin.Drawing.Skia
             var cellWidth = width / 2;
             var cellHeight = height / 2;
 
-            using (var canvas = new SKCanvas(bitmap))
+            using var canvas = new SKCanvas(bitmap);
+            for (var x = 0; x < 2; x++)
             {
-                for (var x = 0; x < 2; x++)
+                for (var y = 0; y < 2; y++)
                 {
-                    for (var y = 0; y < 2; y++)
+                    using var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex);
+                    imageIndex = newIndex;
+
+                    if (currentBitmap == null)
                     {
-                        using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex))
-                        {
-                            imageIndex = newIndex;
-
-                            if (currentBitmap == null)
-                            {
-                                continue;
-                            }
-
-                            using (var resizedBitmap = new SKBitmap(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType))
-                            {
-                                // scale image
-                                currentBitmap.ScalePixels(resizedBitmap, SKFilterQuality.High);
-
-                                // draw this image into the strip at the next position
-                                var xPos = x * cellWidth;
-                                var yPos = y * cellHeight;
-                                canvas.DrawBitmap(resizedBitmap, xPos, yPos);
-                            }
-                        }
+                        continue;
                     }
+
+                    // Scale image. The FromBitmap creates a copy
+                    var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
+                    using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(bitmap, imageInfo));
+
+                    // draw this image into the strip at the next position
+                    var xPos = x * cellWidth;
+                    var yPos = y * cellHeight;
+                    canvas.DrawBitmap(resizedBitmap, xPos, yPos);
                 }
             }
 

+ 24 - 28
Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs

@@ -28,41 +28,37 @@ namespace Jellyfin.Drawing.Skia
             var x = imageSize.Width - OffsetFromTopRightCorner;
             var text = count.ToString(CultureInfo.InvariantCulture);
 
-            using (var paint = new SKPaint())
+            using var paint = new SKPaint
             {
-                paint.Color = SKColor.Parse("#CC00A4DC");
-                paint.Style = SKPaintStyle.Fill;
-                canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
-            }
-
-            using (var paint = new SKPaint())
-            {
-                paint.Color = new SKColor(255, 255, 255, 255);
-                paint.Style = SKPaintStyle.Fill;
+                Color = SKColor.Parse("#CC00A4DC"),
+                Style = SKPaintStyle.Fill
+            };
 
-                paint.TextSize = 24;
-                paint.IsAntialias = true;
+            canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
 
-                var y = OffsetFromTopRightCorner + 9;
+            paint.Color = new SKColor(255, 255, 255, 255);
+            paint.TextSize = 24;
+            paint.IsAntialias = true;
 
-                if (text.Length == 1)
-                {
-                    x -= 7;
-                }
+            var y = OffsetFromTopRightCorner + 9;
 
-                if (text.Length == 2)
-                {
-                    x -= 13;
-                }
-                else if (text.Length >= 3)
-                {
-                    x -= 15;
-                    y -= 2;
-                    paint.TextSize = 18;
-                }
+            if (text.Length == 1)
+            {
+                x -= 7;
+            }
 
-                canvas.DrawText(text, x, y, paint);
+            if (text.Length == 2)
+            {
+                x -= 13;
+            }
+            else if (text.Length >= 3)
+            {
+                x -= 15;
+                y -= 2;
+                paint.TextSize = 18;
             }
+
+            canvas.DrawText(text, x, y, paint);
         }
     }
 }

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

@@ -1,5 +1,6 @@
 #pragma warning disable CS1591
 
+using System;
 using System.Linq;
 using Jellyfin.Data;
 using Jellyfin.Data.Entities;
@@ -27,8 +28,12 @@ namespace Jellyfin.Server.Implementations
 
         public virtual DbSet<ActivityLog> ActivityLogs { get; set; }
 
+        public virtual DbSet<DisplayPreferences> DisplayPreferences { get; set; }
+
         public virtual DbSet<ImageInfo> ImageInfos { get; set; }
 
+        public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; }
+
         public virtual DbSet<Permission> Permissions { get; set; }
 
         public virtual DbSet<Preference> Preferences { get; set; }
@@ -133,6 +138,18 @@ namespace Jellyfin.Server.Implementations
             return base.SaveChanges();
         }
 
+        /// <inheritdoc/>
+        public override void Dispose()
+        {
+            foreach (var entry in ChangeTracker.Entries())
+            {
+                entry.State = EntityState.Detached;
+            }
+
+            GC.SuppressFinalize(this);
+            base.Dispose();
+        }
+
         /// <inheritdoc />
         protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
         {

+ 11 - 3
Jellyfin.Server.Implementations/JellyfinDbProvider.cs

@@ -1,4 +1,6 @@
 using System;
+using System.IO;
+using MediaBrowser.Common.Configuration;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.DependencyInjection;
 
@@ -10,15 +12,20 @@ namespace Jellyfin.Server.Implementations
     public class JellyfinDbProvider
     {
         private readonly IServiceProvider _serviceProvider;
+        private readonly IApplicationPaths _appPaths;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="JellyfinDbProvider"/> class.
         /// </summary>
         /// <param name="serviceProvider">The application's service provider.</param>
-        public JellyfinDbProvider(IServiceProvider serviceProvider)
+        /// <param name="appPaths">The application paths.</param>
+        public JellyfinDbProvider(IServiceProvider serviceProvider, IApplicationPaths appPaths)
         {
             _serviceProvider = serviceProvider;
-            serviceProvider.GetRequiredService<JellyfinDb>().Database.Migrate();
+            _appPaths = appPaths;
+
+            using var jellyfinDb = CreateContext();
+            jellyfinDb.Database.Migrate();
         }
 
         /// <summary>
@@ -27,7 +34,8 @@ namespace Jellyfin.Server.Implementations
         /// <returns>The newly created context.</returns>
         public JellyfinDb CreateContext()
         {
-            return _serviceProvider.GetRequiredService<JellyfinDb>();
+            var contextOptions = new DbContextOptionsBuilder<JellyfinDb>().UseSqlite($"Filename={Path.Combine(_appPaths.DataPath, "jellyfin.db")}");
+            return ActivatorUtilities.CreateInstance<JellyfinDb>(_serviceProvider, contextOptions.Options);
         }
     }
 }

+ 459 - 0
Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs

@@ -0,0 +1,459 @@
+#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("20200728005145_AddDisplayPreferences")]
+    partial class AddDisplayPreferences
+    {
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder
+                .HasDefaultSchema("jellyfin")
+                .HasAnnotation("ProductVersion", "3.1.6");
+
+            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")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(256);
+
+                    b.Property<int>("LogSeverity")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(512);
+
+                    b.Property<string>("Overview")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(512);
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ShortOverview")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(512);
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(256);
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("ActivityLogs");
+                });
+
+            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()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    b.Property<string>("DashboardTheme")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    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")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId")
+                        .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()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(512);
+
+                    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()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    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()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(64);
+
+                    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()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(65535);
+
+                    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")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(255);
+
+                    b.Property<string>("AuthenticationProviderId")
+                        .IsRequired()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(255);
+
+                    b.Property<bool>("DisplayCollectionsView")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("DisplayMissingEpisodes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("EasyPassword")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(65535);
+
+                    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?>("MaxParentalAgeRating")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("MustUpdatePassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Password")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(65535);
+
+                    b.Property<string>("PasswordResetProviderId")
+                        .IsRequired()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(255);
+
+                    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")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(255);
+
+                    b.Property<int>("SubtitleMode")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SyncPlayAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Username")
+                        .IsRequired()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(255);
+
+                    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");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}

+ 132 - 0
Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs

@@ -0,0 +1,132 @@
+#pragma warning disable CS1591
+#pragma warning disable SA1601
+
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    public partial class AddDisplayPreferences : Migration
+    {
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.CreateTable(
+                name: "DisplayPreferences",
+                schema: "jellyfin",
+                columns: table => new
+                {
+                    Id = table.Column<int>(nullable: false)
+                        .Annotation("Sqlite:Autoincrement", true),
+                    UserId = table.Column<Guid>(nullable: false),
+                    Client = table.Column<string>(maxLength: 32, nullable: false),
+                    ShowSidebar = table.Column<bool>(nullable: false),
+                    ShowBackdrop = table.Column<bool>(nullable: false),
+                    ScrollDirection = table.Column<int>(nullable: false),
+                    IndexBy = table.Column<int>(nullable: true),
+                    SkipForwardLength = table.Column<int>(nullable: false),
+                    SkipBackwardLength = table.Column<int>(nullable: false),
+                    ChromecastVersion = table.Column<int>(nullable: false),
+                    EnableNextVideoInfoOverlay = table.Column<bool>(nullable: false),
+                    DashboardTheme = table.Column<string>(maxLength: 32, nullable: true),
+                    TvHome = table.Column<string>(maxLength: 32, nullable: true)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_DisplayPreferences", x => x.Id);
+                    table.ForeignKey(
+                        name: "FK_DisplayPreferences_Users_UserId",
+                        column: x => x.UserId,
+                        principalSchema: "jellyfin",
+                        principalTable: "Users",
+                        principalColumn: "Id",
+                        onDelete: ReferentialAction.Cascade);
+                });
+
+            migrationBuilder.CreateTable(
+                name: "ItemDisplayPreferences",
+                schema: "jellyfin",
+                columns: table => new
+                {
+                    Id = table.Column<int>(nullable: false)
+                        .Annotation("Sqlite:Autoincrement", true),
+                    UserId = table.Column<Guid>(nullable: false),
+                    ItemId = table.Column<Guid>(nullable: false),
+                    Client = table.Column<string>(maxLength: 32, nullable: false),
+                    ViewType = table.Column<int>(nullable: false),
+                    RememberIndexing = table.Column<bool>(nullable: false),
+                    IndexBy = table.Column<int>(nullable: true),
+                    RememberSorting = table.Column<bool>(nullable: false),
+                    SortBy = table.Column<string>(maxLength: 64, nullable: false),
+                    SortOrder = table.Column<int>(nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_ItemDisplayPreferences", x => x.Id);
+                    table.ForeignKey(
+                        name: "FK_ItemDisplayPreferences_Users_UserId",
+                        column: x => x.UserId,
+                        principalSchema: "jellyfin",
+                        principalTable: "Users",
+                        principalColumn: "Id",
+                        onDelete: ReferentialAction.Cascade);
+                });
+
+            migrationBuilder.CreateTable(
+                name: "HomeSection",
+                schema: "jellyfin",
+                columns: table => new
+                {
+                    Id = table.Column<int>(nullable: false)
+                        .Annotation("Sqlite:Autoincrement", true),
+                    DisplayPreferencesId = table.Column<int>(nullable: false),
+                    Order = table.Column<int>(nullable: false),
+                    Type = table.Column<int>(nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_HomeSection", x => x.Id);
+                    table.ForeignKey(
+                        name: "FK_HomeSection_DisplayPreferences_DisplayPreferencesId",
+                        column: x => x.DisplayPreferencesId,
+                        principalSchema: "jellyfin",
+                        principalTable: "DisplayPreferences",
+                        principalColumn: "Id",
+                        onDelete: ReferentialAction.Cascade);
+                });
+
+            migrationBuilder.CreateIndex(
+                name: "IX_DisplayPreferences_UserId",
+                schema: "jellyfin",
+                table: "DisplayPreferences",
+                column: "UserId",
+                unique: true);
+
+            migrationBuilder.CreateIndex(
+                name: "IX_HomeSection_DisplayPreferencesId",
+                schema: "jellyfin",
+                table: "HomeSection",
+                column: "DisplayPreferencesId");
+
+            migrationBuilder.CreateIndex(
+                name: "IX_ItemDisplayPreferences_UserId",
+                schema: "jellyfin",
+                table: "ItemDisplayPreferences",
+                column: "UserId");
+        }
+
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropTable(
+                name: "HomeSection",
+                schema: "jellyfin");
+
+            migrationBuilder.DropTable(
+                name: "ItemDisplayPreferences",
+                schema: "jellyfin");
+
+            migrationBuilder.DropTable(
+                name: "DisplayPreferences",
+                schema: "jellyfin");
+        }
+    }
+}

+ 148 - 1
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.4");
+                .HasAnnotation("ProductVersion", "3.1.6");
 
             modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
                 {
@@ -88,6 +88,82 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.ToTable("ActivityLogs");
                 });
 
+            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()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    b.Property<string>("DashboardTheme")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    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")
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId")
+                        .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")
@@ -113,6 +189,50 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.ToTable("ImageInfos");
                 });
 
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(32);
+
+                    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()
+                        .HasColumnType("TEXT")
+                        .HasMaxLength(64);
+
+                    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")
@@ -282,6 +402,24 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .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)
@@ -289,6 +427,15 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .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)

+ 12 - 16
Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs

@@ -4,13 +4,14 @@ using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Security.Cryptography;
+using System.Text.Json;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
+using MediaBrowser.Common;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Users;
 
 namespace Jellyfin.Server.Implementations.Users
@@ -22,8 +23,7 @@ namespace Jellyfin.Server.Implementations.Users
     {
         private const string BaseResetFileName = "passwordreset";
 
-        private readonly IJsonSerializer _jsonSerializer;
-        private readonly IUserManager _userManager;
+        private readonly IApplicationHost _appHost;
 
         private readonly string _passwordResetFileBase;
         private readonly string _passwordResetFileBaseDir;
@@ -32,17 +32,13 @@ namespace Jellyfin.Server.Implementations.Users
         /// Initializes a new instance of the <see cref="DefaultPasswordResetProvider"/> class.
         /// </summary>
         /// <param name="configurationManager">The configuration manager.</param>
-        /// <param name="jsonSerializer">The JSON serializer.</param>
-        /// <param name="userManager">The user manager.</param>
-        public DefaultPasswordResetProvider(
-            IServerConfigurationManager configurationManager,
-            IJsonSerializer jsonSerializer,
-            IUserManager userManager)
+        /// <param name="appHost">The application host.</param>
+        public DefaultPasswordResetProvider(IServerConfigurationManager configurationManager, IApplicationHost appHost)
         {
             _passwordResetFileBaseDir = configurationManager.ApplicationPaths.ProgramDataPath;
             _passwordResetFileBase = Path.Combine(_passwordResetFileBaseDir, BaseResetFileName);
-            _jsonSerializer = jsonSerializer;
-            _userManager = userManager;
+            _appHost = appHost;
+            // TODO: Remove the circular dependency on UserManager
         }
 
         /// <inheritdoc />
@@ -54,13 +50,14 @@ namespace Jellyfin.Server.Implementations.Users
         /// <inheritdoc />
         public async Task<PinRedeemResult> RedeemPasswordResetPin(string pin)
         {
+            var userManager = _appHost.Resolve<IUserManager>();
             var usersReset = new List<string>();
             foreach (var resetFile in Directory.EnumerateFiles(_passwordResetFileBaseDir, $"{BaseResetFileName}*"))
             {
                 SerializablePasswordReset spr;
                 await using (var str = File.OpenRead(resetFile))
                 {
-                    spr = await _jsonSerializer.DeserializeFromStreamAsync<SerializablePasswordReset>(str).ConfigureAwait(false);
+                    spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false);
                 }
 
                 if (spr.ExpirationDate < DateTime.UtcNow)
@@ -72,10 +69,10 @@ namespace Jellyfin.Server.Implementations.Users
                     pin.Replace("-", string.Empty, StringComparison.Ordinal),
                     StringComparison.InvariantCultureIgnoreCase))
                 {
-                    var resetUser = _userManager.GetUserByName(spr.UserName)
+                    var resetUser = userManager.GetUserByName(spr.UserName)
                         ?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
 
-                    await _userManager.ChangePassword(resetUser, pin).ConfigureAwait(false);
+                    await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false);
                     usersReset.Add(resetUser.Username);
                     File.Delete(resetFile);
                 }
@@ -116,12 +113,11 @@ namespace Jellyfin.Server.Implementations.Users
 
             await using (FileStream fileStream = File.OpenWrite(filePath))
             {
-                _jsonSerializer.SerializeToStream(spr, fileStream);
+                await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
                 await fileStream.FlushAsync().ConfigureAwait(false);
             }
 
             user.EasyPassword = pin;
-            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
             return new ForgotPasswordResult
             {

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

@@ -0,0 +1,88 @@
+#pragma warning disable CA1307
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Server.Implementations.Users
+{
+    /// <summary>
+    /// Manages the storage and retrieval of display preferences through Entity Framework.
+    /// </summary>
+    public class DisplayPreferencesManager : IDisplayPreferencesManager
+    {
+        private readonly JellyfinDbProvider _dbProvider;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class.
+        /// </summary>
+        /// <param name="dbProvider">The Jellyfin db provider.</param>
+        public DisplayPreferencesManager(JellyfinDbProvider dbProvider)
+        {
+            _dbProvider = dbProvider;
+        }
+
+        /// <inheritdoc />
+        public DisplayPreferences GetDisplayPreferences(Guid userId, string client)
+        {
+            using var dbContext = _dbProvider.CreateContext();
+            var prefs = dbContext.DisplayPreferences
+                .Include(pref => pref.HomeSections)
+                .FirstOrDefault(pref =>
+                    pref.UserId == userId && string.Equals(pref.Client, client));
+
+            if (prefs == null)
+            {
+                prefs = new DisplayPreferences(userId, client);
+                dbContext.DisplayPreferences.Add(prefs);
+            }
+
+            return prefs;
+        }
+
+        /// <inheritdoc />
+        public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client)
+        {
+            using var dbContext = _dbProvider.CreateContext();
+            var prefs = dbContext.ItemDisplayPreferences
+                .FirstOrDefault(pref => pref.UserId == userId && pref.ItemId == itemId && string.Equals(pref.Client, client));
+
+            if (prefs == null)
+            {
+                prefs = new ItemDisplayPreferences(userId, Guid.Empty, client);
+                dbContext.ItemDisplayPreferences.Add(prefs);
+            }
+
+            return prefs;
+        }
+
+        /// <inheritdoc />
+        public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client)
+        {
+            using var dbContext = _dbProvider.CreateContext();
+
+            return dbContext.ItemDisplayPreferences
+                .Where(prefs => prefs.UserId == userId && prefs.ItemId != Guid.Empty && string.Equals(prefs.Client, client))
+                .ToList();
+        }
+
+        /// <inheritdoc />
+        public void SaveChanges(DisplayPreferences preferences)
+        {
+            using var dbContext = _dbProvider.CreateContext();
+            dbContext.Update(preferences);
+            dbContext.SaveChanges();
+        }
+
+        /// <inheritdoc />
+        public void SaveChanges(ItemDisplayPreferences preferences)
+        {
+            using var dbContext = _dbProvider.CreateContext();
+            dbContext.Update(preferences);
+            dbContext.SaveChanges();
+        }
+    }
+}

+ 60 - 48
Jellyfin.Server.Implementations/Users/UserManager.cs

@@ -39,12 +39,11 @@ namespace Jellyfin.Server.Implementations.Users
         private readonly IApplicationHost _appHost;
         private readonly IImageProcessor _imageProcessor;
         private readonly ILogger<UserManager> _logger;
-
-        private IAuthenticationProvider[] _authenticationProviders = null!;
-        private DefaultAuthenticationProvider _defaultAuthenticationProvider = null!;
-        private InvalidAuthProvider _invalidAuthProvider = null!;
-        private IPasswordResetProvider[] _passwordResetProviders = null!;
-        private DefaultPasswordResetProvider _defaultPasswordResetProvider = null!;
+        private readonly IReadOnlyCollection<IPasswordResetProvider> _passwordResetProviders;
+        private readonly IReadOnlyCollection<IAuthenticationProvider> _authenticationProviders;
+        private readonly InvalidAuthProvider _invalidAuthProvider;
+        private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider;
+        private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -69,6 +68,13 @@ namespace Jellyfin.Server.Implementations.Users
             _appHost = appHost;
             _imageProcessor = imageProcessor;
             _logger = logger;
+
+            _passwordResetProviders = appHost.GetExports<IPasswordResetProvider>();
+            _authenticationProviders = appHost.GetExports<IAuthenticationProvider>();
+
+            _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
+            _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
+            _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
         }
 
         /// <inheritdoc/>
@@ -102,7 +108,16 @@ namespace Jellyfin.Server.Implementations.Users
         }
 
         /// <inheritdoc/>
-        public IEnumerable<Guid> UsersIds => _dbProvider.CreateContext().Users.Select(u => u.Id);
+        public IEnumerable<Guid> UsersIds
+        {
+            get
+            {
+                using var dbContext = _dbProvider.CreateContext();
+                return dbContext.Users
+                    .Select(user => user.Id)
+                    .ToList();
+            }
+        }
 
         /// <inheritdoc/>
         public User? GetUserById(Guid id)
@@ -188,8 +203,24 @@ namespace Jellyfin.Server.Implementations.Users
             await dbContext.SaveChangesAsync().ConfigureAwait(false);
         }
 
+        internal async Task<User> CreateUserInternalAsync(string name, JellyfinDb dbContext)
+        {
+            // TODO: Remove after user item data is migrated.
+            var max = await dbContext.Users.AnyAsync().ConfigureAwait(false)
+                ? await dbContext.Users.Select(u => u.InternalId).MaxAsync().ConfigureAwait(false)
+                : 0;
+
+            return new User(
+                name,
+                _defaultAuthenticationProvider.GetType().FullName,
+                _defaultPasswordResetProvider.GetType().FullName)
+            {
+                InternalId = max + 1
+            };
+        }
+
         /// <inheritdoc/>
-        public User CreateUser(string name)
+        public async Task<User> CreateUserAsync(string name)
         {
             if (!IsValidUsername(name))
             {
@@ -198,18 +229,10 @@ namespace Jellyfin.Server.Implementations.Users
 
             using var dbContext = _dbProvider.CreateContext();
 
-            // TODO: Remove after user item data is migrated.
-            var max = dbContext.Users.Any() ? dbContext.Users.Select(u => u.InternalId).Max() : 0;
+            var newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
 
-            var newUser = new User(
-                name,
-                _defaultAuthenticationProvider.GetType().FullName,
-                _defaultPasswordResetProvider.GetType().FullName)
-            {
-                InternalId = max + 1
-            };
             dbContext.Users.Add(newUser);
-            dbContext.SaveChanges();
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
 
             OnUserCreated?.Invoke(this, new GenericEventArgs<User>(newUser));
 
@@ -512,7 +535,7 @@ namespace Jellyfin.Server.Implementations.Users
             }
             else
             {
-                IncrementInvalidLoginAttemptCount(user);
+                await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
                 _logger.LogInformation(
                     "Authentication request for {UserName} has been denied (IP: {IP}).",
                     user.Username,
@@ -530,7 +553,12 @@ namespace Jellyfin.Server.Implementations.Users
             if (user != null && isInNetwork)
             {
                 var passwordResetProvider = GetPasswordResetProvider(user);
-                return await passwordResetProvider.StartForgotPasswordProcess(user, isInNetwork).ConfigureAwait(false);
+                var result = await passwordResetProvider
+                    .StartForgotPasswordProcess(user, isInNetwork)
+                    .ConfigureAwait(false);
+
+                await UpdateUserAsync(user).ConfigureAwait(false);
+                return result;
             }
 
             return new ForgotPasswordResult
@@ -560,48 +588,32 @@ namespace Jellyfin.Server.Implementations.Users
             };
         }
 
-        /// <inheritdoc/>
-        public void AddParts(IEnumerable<IAuthenticationProvider> authenticationProviders, IEnumerable<IPasswordResetProvider> passwordResetProviders)
-        {
-            _authenticationProviders = authenticationProviders.ToArray();
-            _passwordResetProviders = passwordResetProviders.ToArray();
-
-            _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
-            _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
-            _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
-        }
-
         /// <inheritdoc />
-        public void Initialize()
+        public async Task InitializeAsync()
         {
             // TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
             using var dbContext = _dbProvider.CreateContext();
 
-            if (dbContext.Users.Any())
+            if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
             {
                 return;
             }
 
             var defaultName = Environment.UserName;
-            if (string.IsNullOrWhiteSpace(defaultName))
+            if (string.IsNullOrWhiteSpace(defaultName) || !IsValidUsername(defaultName))
             {
                 defaultName = "MyJellyfinUser";
             }
 
             _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
 
-            if (!IsValidUsername(defaultName))
-            {
-                throw new ArgumentException("Provided username is not valid!", defaultName);
-            }
-
-            var newUser = CreateUser(defaultName);
+            var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
             newUser.SetPermission(PermissionKind.IsAdministrator, true);
             newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
             newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true);
 
-            dbContext.Users.Update(newUser);
-            dbContext.SaveChanges();
+            dbContext.Users.Add(newUser);
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
         }
 
         /// <inheritdoc/>
@@ -637,7 +649,7 @@ namespace Jellyfin.Server.Implementations.Users
         /// <inheritdoc/>
         public void UpdateConfiguration(Guid userId, UserConfiguration config)
         {
-            var dbContext = _dbProvider.CreateContext();
+            using var dbContext = _dbProvider.CreateContext();
             var user = dbContext.Users
                            .Include(u => u.Permissions)
                            .Include(u => u.Preferences)
@@ -670,7 +682,7 @@ namespace Jellyfin.Server.Implementations.Users
         /// <inheritdoc/>
         public void UpdatePolicy(Guid userId, UserPolicy policy)
         {
-            var dbContext = _dbProvider.CreateContext();
+            using var dbContext = _dbProvider.CreateContext();
             var user = dbContext.Users
                            .Include(u => u.Permissions)
                            .Include(u => u.Preferences)
@@ -749,8 +761,8 @@ namespace Jellyfin.Server.Implementations.Users
         {
             // This is some regex that matches only on unicode "word" characters, as well as -, _ and @
             // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
-            // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), and periods (.)
-            return Regex.IsMatch(name, @"^[\w\-'._@]*$");
+            // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( )
+            return Regex.IsMatch(name, @"^[\w\ \-'._@]*$");
         }
 
         private IAuthenticationProvider GetAuthenticationProvider(User user)
@@ -882,7 +894,7 @@ namespace Jellyfin.Server.Implementations.Users
             }
         }
 
-        private void IncrementInvalidLoginAttemptCount(User user)
+        private async Task IncrementInvalidLoginAttemptCount(User user)
         {
             user.InvalidLoginAttemptCount++;
             int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
@@ -896,7 +908,7 @@ namespace Jellyfin.Server.Implementations.Users
                     user.InvalidLoginAttemptCount);
             }
 
-            UpdateUser(user);
+            await UpdateUserAsync(user).ConfigureAwait(false);
         }
     }
 }

+ 10 - 7
Jellyfin.Server/CoreAppHost.cs

@@ -9,6 +9,7 @@ using Jellyfin.Server.Implementations;
 using Jellyfin.Server.Implementations.Activity;
 using Jellyfin.Server.Implementations.Users;
 using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Activity;
@@ -33,9 +34,9 @@ namespace Jellyfin.Server
         /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param>
         /// <param name="networkManager">The <see cref="INetworkManager" /> to be used by the <see cref="CoreAppHost" />.</param>
         public CoreAppHost(
-            ServerApplicationPaths applicationPaths,
+            IServerApplicationPaths applicationPaths,
             ILoggerFactory loggerFactory,
-            StartupOptions options,
+            IStartupOptions options,
             IFileSystem fileSystem,
             INetworkManager networkManager)
             : base(
@@ -63,16 +64,18 @@ namespace Jellyfin.Server
                 Logger.LogWarning($"Skia not available. Will fallback to {nameof(NullImageEncoder)}.");
             }
 
-            // TODO: Set up scoping and use AddDbContextPool
-            serviceCollection.AddDbContext<JellyfinDb>(
-                options => options
-                    .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"),
-                ServiceLifetime.Transient);
+            // TODO: Set up scoping and use AddDbContextPool,
+            // can't register as Transient since tracking transient in GC is funky
+            // serviceCollection.AddDbContext<JellyfinDb>(
+            //     options => options
+            //         .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"),
+            //     ServiceLifetime.Transient);
 
             serviceCollection.AddSingleton<JellyfinDbProvider>();
 
             serviceCollection.AddSingleton<IActivityManager, ActivityManager>();
             serviceCollection.AddSingleton<IUserManager, UserManager>();
+            serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
 
             base.RegisterServices(serviceCollection);
         }

+ 1 - 1
Jellyfin.Server/Jellyfin.Server.csproj

@@ -45,7 +45,7 @@
     <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.6" />
     <PackageReference Include="prometheus-net" Version="3.6.0" />
     <PackageReference Include="prometheus-net.AspNetCore" Version="3.6.0" />
-    <PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
+    <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
     <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
     <PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
     <PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" />

+ 3 - 1
Jellyfin.Server/Migrations/MigrationRunner.cs

@@ -21,7 +21,9 @@ namespace Jellyfin.Server.Migrations
             typeof(Routines.MigrateActivityLogDb),
             typeof(Routines.RemoveDuplicateExtras),
             typeof(Routines.AddDefaultPluginRepository),
-            typeof(Routines.MigrateUserDb)
+            typeof(Routines.MigrateUserDb),
+            typeof(Routines.ReaddDefaultPluginRepository),
+            typeof(Routines.MigrateDisplayPreferencesDb)
         };
 
         /// <summary>

+ 174 - 0
Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs

@@ -0,0 +1,174 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using Jellyfin.Server.Implementations;
+using MediaBrowser.Controller;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+using SQLitePCL.pretty;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+    /// <summary>
+    /// The migration routine for migrating the display preferences database to EF Core.
+    /// </summary>
+    public class MigrateDisplayPreferencesDb : IMigrationRoutine
+    {
+        private const string DbFilename = "displaypreferences.db";
+
+        private readonly ILogger<MigrateDisplayPreferencesDb> _logger;
+        private readonly IServerApplicationPaths _paths;
+        private readonly JellyfinDbProvider _provider;
+        private readonly JsonSerializerOptions _jsonOptions;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="MigrateDisplayPreferencesDb"/> class.
+        /// </summary>
+        /// <param name="logger">The logger.</param>
+        /// <param name="paths">The server application paths.</param>
+        /// <param name="provider">The database provider.</param>
+        public MigrateDisplayPreferencesDb(ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, JellyfinDbProvider provider)
+        {
+            _logger = logger;
+            _paths = paths;
+            _provider = provider;
+            _jsonOptions = new JsonSerializerOptions();
+            _jsonOptions.Converters.Add(new JsonStringEnumConverter());
+        }
+
+        /// <inheritdoc />
+        public Guid Id => Guid.Parse("06387815-C3CC-421F-A888-FB5F9992BEA8");
+
+        /// <inheritdoc />
+        public string Name => "MigrateDisplayPreferencesDatabase";
+
+        /// <inheritdoc />
+        public bool PerformOnNewInstall => false;
+
+        /// <inheritdoc />
+        public void Perform()
+        {
+            HomeSectionType[] defaults =
+            {
+                HomeSectionType.SmallLibraryTiles,
+                HomeSectionType.Resume,
+                HomeSectionType.ResumeAudio,
+                HomeSectionType.LiveTv,
+                HomeSectionType.NextUp,
+                HomeSectionType.LatestMedia,
+                HomeSectionType.None,
+            };
+
+            var chromecastDict = new Dictionary<string, ChromecastVersion>(StringComparer.OrdinalIgnoreCase)
+            {
+                { "stable", ChromecastVersion.Stable },
+                { "nightly", ChromecastVersion.Unstable },
+                { "unstable", ChromecastVersion.Unstable }
+            };
+
+            var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
+            using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null))
+            {
+                using var dbContext = _provider.CreateContext();
+
+                var results = connection.Query("SELECT * FROM userdisplaypreferences");
+                foreach (var result in results)
+                {
+                    var dto = JsonSerializer.Deserialize<DisplayPreferencesDto>(result[3].ToString(), _jsonOptions);
+                    var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version)
+                        ? chromecastDict[version]
+                        : ChromecastVersion.Stable;
+
+                    var displayPreferences = new DisplayPreferences(new Guid(result[1].ToBlob()), result[2].ToString())
+                    {
+                        IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null,
+                        ShowBackdrop = dto.ShowBackdrop,
+                        ShowSidebar = dto.ShowSidebar,
+                        ScrollDirection = dto.ScrollDirection,
+                        ChromecastVersion = chromecastVersion,
+                        SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length)
+                            ? int.Parse(length, CultureInfo.InvariantCulture)
+                            : 30000,
+                        SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length)
+                            ? int.Parse(length, CultureInfo.InvariantCulture)
+                            : 10000,
+                        EnableNextVideoInfoOverlay = dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled)
+                            ? bool.Parse(enabled)
+                            : true,
+                        DashboardTheme = dto.CustomPrefs.TryGetValue("dashboardtheme", out var theme) ? theme : string.Empty,
+                        TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty
+                    };
+
+                    for (int i = 0; i < 7; i++)
+                    {
+                        dto.CustomPrefs.TryGetValue("homesection" + i, out var homeSection);
+
+                        displayPreferences.HomeSections.Add(new HomeSection
+                        {
+                            Order = i,
+                            Type = Enum.TryParse<HomeSectionType>(homeSection, true, out var type) ? type : defaults[i]
+                        });
+                    }
+
+                    var defaultLibraryPrefs = new ItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client)
+                    {
+                        SortBy = dto.SortBy ?? "SortName",
+                        SortOrder = dto.SortOrder,
+                        RememberIndexing = dto.RememberIndexing,
+                        RememberSorting = dto.RememberSorting,
+                    };
+
+                    dbContext.Add(defaultLibraryPrefs);
+
+                    foreach (var key in dto.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.Ordinal)))
+                    {
+                        if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var itemId))
+                        {
+                            continue;
+                        }
+
+                        var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client)
+                        {
+                            SortBy = dto.SortBy ?? "SortName",
+                            SortOrder = dto.SortOrder,
+                            RememberIndexing = dto.RememberIndexing,
+                            RememberSorting = dto.RememberSorting,
+                        };
+
+                        if (Enum.TryParse<ViewType>(dto.ViewType, true, out var viewType))
+                        {
+                            libraryDisplayPreferences.ViewType = viewType;
+                        }
+
+                        dbContext.ItemDisplayPreferences.Add(libraryDisplayPreferences);
+                    }
+
+                    dbContext.Add(displayPreferences);
+                }
+
+                dbContext.SaveChanges();
+            }
+
+            try
+            {
+                File.Move(dbFilePath, dbFilePath + ".old");
+
+                var journalPath = dbFilePath + "-journal";
+                if (File.Exists(journalPath))
+                {
+                    File.Move(journalPath, dbFilePath + ".old-journal");
+                }
+            }
+            catch (IOException e)
+            {
+                _logger.LogError(e, "Error renaming legacy display preferences database to 'displaypreferences.db.old'");
+            }
+        }
+    }
+}

+ 49 - 0
Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs

@@ -0,0 +1,49 @@
+using System;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Updates;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+    /// <summary>
+    /// Migration to initialize system configuration with the default plugin repository.
+    /// </summary>
+    public class ReaddDefaultPluginRepository : IMigrationRoutine
+    {
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo
+        {
+            Name = "Jellyfin Stable",
+            Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"
+        };
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ReaddDefaultPluginRepository"/> class.
+        /// </summary>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public ReaddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
+        {
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <inheritdoc/>
+        public Guid Id => Guid.Parse("5F86E7F6-D966-4C77-849D-7A7B40B68C4E");
+
+        /// <inheritdoc/>
+        public string Name => "ReaddDefaultPluginRepository";
+
+        /// <inheritdoc/>
+        public bool PerformOnNewInstall => true;
+
+        /// <inheritdoc/>
+        public void Perform()
+        {
+            // Only add if repository list is empty
+            if (_serverConfigurationManager.Configuration.PluginRepositories.Count == 0)
+            {
+                _serverConfigurationManager.Configuration.PluginRepositories.Add(_defaultRepositoryInfo);
+                _serverConfigurationManager.SaveConfiguration();
+            }
+        }
+    }
+}

+ 15 - 0
Jellyfin.Server/Program.cs

@@ -343,6 +343,21 @@ namespace Jellyfin.Server
                             }
                         }
                     }
+
+                    // Bind to unix socket (only on OSX and Linux)
+                    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+                    {
+                        // TODO: allow configuration of socket path
+                        var socketPath = $"{appPaths.DataPath}/socket.sock";
+                        // Workaround for https://github.com/aspnet/AspNetCore/issues/14134
+                        if (File.Exists(socketPath))
+                        {
+                            File.Delete(socketPath);
+                        }
+
+                        options.ListenUnixSocket(socketPath);
+                        _logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath);
+                    }
                 })
                 .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(commandLineOpts, appPaths, startupConfig))
                 .UseSerilog()

+ 189 - 0
MediaBrowser.Api/DisplayPreferencesService.cs

@@ -0,0 +1,189 @@
+using System;
+using System.Linq;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Services;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Api
+{
+    /// <summary>
+    /// Class UpdateDisplayPreferences.
+    /// </summary>
+    [Route("/DisplayPreferences/{DisplayPreferencesId}", "POST", Summary = "Updates a user's display preferences for an item")]
+    public class UpdateDisplayPreferences : DisplayPreferencesDto, IReturnVoid
+    {
+        /// <summary>
+        /// Gets or sets the id.
+        /// </summary>
+        /// <value>The id.</value>
+        [ApiMember(Name = "DisplayPreferencesId", Description = "DisplayPreferences Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string DisplayPreferencesId { get; set; }
+
+        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
+        public string UserId { get; set; }
+    }
+
+    [Route("/DisplayPreferences/{Id}", "GET", Summary = "Gets a user's display preferences for an item")]
+    public class GetDisplayPreferences : IReturn<DisplayPreferencesDto>
+    {
+        /// <summary>
+        /// Gets or sets the id.
+        /// </summary>
+        /// <value>The id.</value>
+        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
+        public string Id { get; set; }
+
+        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
+        public string UserId { get; set; }
+
+        [ApiMember(Name = "Client", Description = "Client", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
+        public string Client { get; set; }
+    }
+
+    /// <summary>
+    /// Class DisplayPreferencesService.
+    /// </summary>
+    [Authenticated]
+    public class DisplayPreferencesService : BaseApiService
+    {
+        /// <summary>
+        /// The display preferences manager.
+        /// </summary>
+        private readonly IDisplayPreferencesManager _displayPreferencesManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DisplayPreferencesService" /> class.
+        /// </summary>
+        /// <param name="displayPreferencesManager">The display preferences manager.</param>
+        public DisplayPreferencesService(
+            ILogger<DisplayPreferencesService> logger,
+            IServerConfigurationManager serverConfigurationManager,
+            IHttpResultFactory httpResultFactory,
+            IDisplayPreferencesManager displayPreferencesManager)
+            : base(logger, serverConfigurationManager, httpResultFactory)
+        {
+            _displayPreferencesManager = displayPreferencesManager;
+        }
+
+        /// <summary>
+        /// Gets the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public object Get(GetDisplayPreferences request)
+        {
+            var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(Guid.Parse(request.UserId), request.Client);
+            var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client);
+
+            var dto = new DisplayPreferencesDto
+            {
+                Client = displayPreferences.Client,
+                Id = displayPreferences.UserId.ToString(),
+                ViewType = itemPreferences.ViewType.ToString(),
+                SortBy = itemPreferences.SortBy,
+                SortOrder = itemPreferences.SortOrder,
+                IndexBy = displayPreferences.IndexBy?.ToString(),
+                RememberIndexing = itemPreferences.RememberIndexing,
+                RememberSorting = itemPreferences.RememberSorting,
+                ScrollDirection = displayPreferences.ScrollDirection,
+                ShowBackdrop = displayPreferences.ShowBackdrop,
+                ShowSidebar = displayPreferences.ShowSidebar
+            };
+
+            foreach (var homeSection in displayPreferences.HomeSections)
+            {
+                dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
+            }
+
+            foreach (var itemDisplayPreferences in _displayPreferencesManager.ListItemDisplayPreferences(displayPreferences.UserId, displayPreferences.Client))
+            {
+                dto.CustomPrefs["landing-" + itemDisplayPreferences.ItemId] = itemDisplayPreferences.ViewType.ToString().ToLowerInvariant();
+            }
+
+            dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
+            dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString();
+            dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString();
+            dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString();
+            dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
+
+            return ToOptimizedResult(dto);
+        }
+
+        /// <summary>
+        /// Posts the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(UpdateDisplayPreferences request)
+        {
+            HomeSectionType[] defaults =
+            {
+                HomeSectionType.SmallLibraryTiles,
+                HomeSectionType.Resume,
+                HomeSectionType.ResumeAudio,
+                HomeSectionType.LiveTv,
+                HomeSectionType.NextUp,
+                HomeSectionType.LatestMedia,
+                HomeSectionType.None,
+            };
+
+            var prefs = _displayPreferencesManager.GetDisplayPreferences(Guid.Parse(request.UserId), request.Client);
+
+            prefs.IndexBy = Enum.TryParse<IndexingKind>(request.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null;
+            prefs.ShowBackdrop = request.ShowBackdrop;
+            prefs.ShowSidebar = request.ShowSidebar;
+
+            prefs.ScrollDirection = request.ScrollDirection;
+            prefs.ChromecastVersion = request.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
+                ? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
+                : ChromecastVersion.Stable;
+            prefs.EnableNextVideoInfoOverlay = request.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
+                ? bool.Parse(enableNextVideoInfoOverlay)
+                : true;
+            prefs.SkipBackwardLength = request.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) ? int.Parse(skipBackLength) : 10000;
+            prefs.SkipForwardLength = request.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) ? int.Parse(skipForwardLength) : 30000;
+            prefs.DashboardTheme = request.CustomPrefs.TryGetValue("dashboardTheme", out var theme) ? theme : string.Empty;
+            prefs.TvHome = request.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty;
+            prefs.HomeSections.Clear();
+
+            foreach (var key in request.CustomPrefs.Keys.Where(key => key.StartsWith("homesection")))
+            {
+                var order = int.Parse(key.AsSpan().Slice("homesection".Length));
+                if (!Enum.TryParse<HomeSectionType>(request.CustomPrefs[key], true, out var type))
+                {
+                    type = order < 7 ? defaults[order] : HomeSectionType.None;
+                }
+
+                prefs.HomeSections.Add(new HomeSection
+                {
+                    Order = order,
+                    Type = type
+                });
+            }
+
+            foreach (var key in request.CustomPrefs.Keys.Where(key => key.StartsWith("landing-")))
+            {
+                var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(prefs.UserId, Guid.Parse(key.Substring("landing-".Length)), prefs.Client);
+                itemPreferences.ViewType = Enum.Parse<ViewType>(request.ViewType);
+                _displayPreferencesManager.SaveChanges(itemPreferences);
+            }
+
+            var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(prefs.UserId, Guid.Empty, prefs.Client);
+            itemPrefs.SortBy = request.SortBy;
+            itemPrefs.SortOrder = request.SortOrder;
+            itemPrefs.RememberIndexing = request.RememberIndexing;
+            itemPrefs.RememberSorting = request.RememberSorting;
+
+            if (Enum.TryParse<ViewType>(request.ViewType, true, out var viewType))
+            {
+                itemPrefs.ViewType = viewType;
+            }
+
+            _displayPreferencesManager.SaveChanges(prefs);
+            _displayPreferencesManager.SaveChanges(itemPrefs);
+        }
+    }
+}

+ 2 - 1
MediaBrowser.Api/Playback/Hls/BaseHlsService.cs

@@ -194,7 +194,8 @@ namespace MediaBrowser.Api.Playback.Hls
             var paddedBitrate = Convert.ToInt32(bitrate * 1.15);
 
             // Main stream
-            builder.AppendLine("#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=" + paddedBitrate.ToString(CultureInfo.InvariantCulture));
+            builder.Append("#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=")
+                .AppendLine(paddedBitrate.ToString(CultureInfo.InvariantCulture));
             var playlistUrl = "hls/" + Path.GetFileName(firstPlaylist).Replace(".m3u8", "/stream.m3u8");
             builder.AppendLine(playlistUrl);
 

+ 10 - 6
MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs

@@ -361,7 +361,7 @@ namespace MediaBrowser.Api.Playback.Hls
 
             var playlistFilename = Path.GetFileNameWithoutExtension(playlist);
 
-            var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length);
+            var indexString = Path.GetFileNameWithoutExtension(file.Name).AsSpan().Slice(playlistFilename.Length);
 
             return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture);
         }
@@ -960,7 +960,8 @@ namespace MediaBrowser.Api.Playback.Hls
             builder.AppendLine("#EXTM3U");
             builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
             builder.AppendLine("#EXT-X-VERSION:3");
-            builder.AppendLine("#EXT-X-TARGETDURATION:" + Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength).ToString(CultureInfo.InvariantCulture));
+            builder.Append("#EXT-X-TARGETDURATION:")
+                .AppendLine(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength).ToString(CultureInfo.InvariantCulture));
             builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
 
             var queryStringIndex = Request.RawUrl.IndexOf('?');
@@ -975,14 +976,17 @@ namespace MediaBrowser.Api.Playback.Hls
 
             foreach (var length in segmentLengths)
             {
-                builder.AppendLine("#EXTINF:" + length.ToString("0.0000", CultureInfo.InvariantCulture) + ", nodesc");
-
-                builder.AppendLine(string.Format("hls1/{0}/{1}{2}{3}",
+                builder.Append("#EXTINF:")
+                    .Append(length.ToString("0.0000", CultureInfo.InvariantCulture))
+                    .AppendLine(", nodesc");
 
+                builder.AppendFormat(
+                    CultureInfo.InvariantCulture,
+                    "hls1/{0}/{1}{2}{3}",
                     name,
                     index.ToString(CultureInfo.InvariantCulture),
                     GetSegmentFileExtension(request),
-                    queryString));
+                    queryString).AppendLine();
 
                 index++;
             }

+ 33 - 0
MediaBrowser.Common/Extensions/HttpContextExtensions.cs

@@ -0,0 +1,33 @@
+using MediaBrowser.Model.Services;
+using Microsoft.AspNetCore.Http;
+
+namespace MediaBrowser.Common.Extensions
+{
+    /// <summary>
+    /// Static class containing extension methods for <see cref="HttpContext"/>.
+    /// </summary>
+    public static class HttpContextExtensions
+    {
+        private const string ServiceStackRequest = "ServiceStackRequest";
+
+        /// <summary>
+        /// Set the ServiceStack request.
+        /// </summary>
+        /// <param name="httpContext">The HttpContext instance.</param>
+        /// <param name="request">The service stack request instance.</param>
+        public static void SetServiceStackRequest(this HttpContext httpContext, IRequest request)
+        {
+            httpContext.Items[ServiceStackRequest] = request;
+        }
+
+        /// <summary>
+        /// Get the ServiceStack request.
+        /// </summary>
+        /// <param name="httpContext">The HttpContext instance.</param>
+        /// <returns>The service stack request instance.</returns>
+        public static IRequest GetServiceStackRequest(this HttpContext httpContext)
+        {
+            return (IRequest)httpContext.Items[ServiceStackRequest];
+        }
+    }
+}

+ 9 - 7
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -675,11 +675,11 @@ namespace MediaBrowser.Controller.Entities
                 return System.IO.Path.Combine(basePath, "channels", ChannelId.ToString("N", CultureInfo.InvariantCulture), Id.ToString("N", CultureInfo.InvariantCulture));
             }
 
-            var idString = Id.ToString("N", CultureInfo.InvariantCulture);
+            ReadOnlySpan<char> idString = Id.ToString("N", CultureInfo.InvariantCulture);
 
             basePath = System.IO.Path.Combine(basePath, "library");
 
-            return System.IO.Path.Combine(basePath, idString.Substring(0, 2), idString);
+            return System.IO.Path.Join(basePath, idString.Slice(0, 2), idString);
         }
 
         /// <summary>
@@ -702,26 +702,27 @@ namespace MediaBrowser.Controller.Entities
 
             foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
             {
-                sortable = sortable.Replace(removeChar, string.Empty);
+                sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
             }
 
             foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
             {
-                sortable = sortable.Replace(replaceChar, " ");
+                sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
             }
 
             foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
             {
                 // Remove from beginning if a space follows
-                if (sortable.StartsWith(search + " "))
+                if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
                 {
                     sortable = sortable.Remove(0, search.Length + 1);
                 }
+
                 // Remove from middle if surrounded by spaces
-                sortable = sortable.Replace(" " + search + " ", " ");
+                sortable = sortable.Replace(" " + search + " ", " ", StringComparison.Ordinal);
 
                 // Remove from end if followed by a space
-                if (sortable.EndsWith(" " + search))
+                if (sortable.EndsWith(" " + search, StringComparison.Ordinal))
                 {
                     sortable = sortable.Remove(sortable.Length - (search.Length + 1));
                 }
@@ -751,6 +752,7 @@ namespace MediaBrowser.Controller.Entities
 
                 builder.Append(chunkBuilder);
             }
+
             // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString());
             return builder.ToString().RemoveDiacritics();
         }

+ 1 - 0
MediaBrowser.Controller/Entities/UserViewBuilder.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Library;

+ 49 - 0
MediaBrowser.Controller/IDisplayPreferencesManager.cs

@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using Jellyfin.Data.Entities;
+
+namespace MediaBrowser.Controller
+{
+    /// <summary>
+    /// Manages the storage and retrieval of display preferences.
+    /// </summary>
+    public interface IDisplayPreferencesManager
+    {
+        /// <summary>
+        /// Gets the display preferences for the user and client.
+        /// </summary>
+        /// <param name="userId">The user's id.</param>
+        /// <param name="client">The client string.</param>
+        /// <returns>The associated display preferences.</returns>
+        DisplayPreferences GetDisplayPreferences(Guid userId, string client);
+
+        /// <summary>
+        /// Gets the default item display preferences for the user and client.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="client">The client string.</param>
+        /// <returns>The item display preferences.</returns>
+        ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client);
+
+        /// <summary>
+        /// Gets all of the item display preferences for the user and client.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="client">The client string.</param>
+        /// <returns>A list of item display preferences.</returns>
+        IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client);
+
+        /// <summary>
+        /// Saves changes to the provided display preferences.
+        /// </summary>
+        /// <param name="preferences">The display preferences to save.</param>
+        void SaveChanges(DisplayPreferences preferences);
+
+        /// <summary>
+        /// Saves changes to the provided item display preferences.
+        /// </summary>
+        /// <param name="preferences">The item display preferences to save.</param>
+        void SaveChanges(ItemDisplayPreferences preferences);
+    }
+}

+ 2 - 1
MediaBrowser.Controller/Library/ILibraryManager.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
@@ -199,7 +200,7 @@ namespace MediaBrowser.Controller.Library
         /// <summary>
         /// Updates the item.
         /// </summary>
-        void UpdateItems(IEnumerable<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
+        void UpdateItems(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
 
         void UpdateItem(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
 

+ 2 - 5
MediaBrowser.Controller/Library/IUserManager.cs

@@ -2,7 +2,6 @@ using System;
 using System.Collections.Generic;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
-using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Events;
@@ -55,7 +54,7 @@ namespace MediaBrowser.Controller.Library
         /// <summary>
         /// Initializes the user manager and ensures that a user exists.
         /// </summary>
-        void Initialize();
+        Task InitializeAsync();
 
         /// <summary>
         /// Gets a user by Id.
@@ -106,7 +105,7 @@ namespace MediaBrowser.Controller.Library
         /// <returns>The created user.</returns>
         /// <exception cref="ArgumentNullException">name</exception>
         /// <exception cref="ArgumentException"></exception>
-        User CreateUser(string name);
+        Task<User> CreateUserAsync(string name);
 
         /// <summary>
         /// Deletes the specified user.
@@ -166,8 +165,6 @@ namespace MediaBrowser.Controller.Library
         /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
         Task<PinRedeemResult> RedeemPasswordResetPin(string pin);
 
-        void AddParts(IEnumerable<IAuthenticationProvider> authenticationProviders, IEnumerable<IPasswordResetProvider> passwordResetProviders);
-
         NameIdPair[] GetAuthenticationProviders();
 
         NameIdPair[] GetPasswordResetProviders();

部分文件因文件數量過多而無法顯示