Преглед на файлове

Merge remote-tracking branch 'remotes/upstream/api-migration' into api-system

David преди 5 години
родител
ревизия
d63c0e4228
променени са 100 файла, в които са добавени 3693 реда и са изтрити 2245 реда
  1. 2 0
      CONTRIBUTORS.md
  2. 5 3
      Emby.Dlna/ContentDirectory/ContentDirectory.cs
  3. 20 13
      Emby.Dlna/ContentDirectory/ControlHandler.cs
  4. 9 5
      Emby.Dlna/Didl/DidlBuilder.cs
  5. 9 1
      Emby.Dlna/PlayTo/PlayToController.cs
  6. 9 0
      Emby.Drawing/ImageProcessor.cs
  7. 5 3
      Emby.Notifications/NotificationManager.cs
  8. 22 34
      Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs
  9. 1 9
      Emby.Server.Implementations/ApplicationHost.cs
  10. 8 3
      Emby.Server.Implementations/Channels/ChannelManager.cs
  11. 1 0
      Emby.Server.Implementations/Collections/CollectionManager.cs
  12. 1 1
      Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs
  13. 1 0
      Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
  14. 0 240
      Emby.Server.Implementations/Data/SqliteUserRepository.cs
  15. 9 20
      Emby.Server.Implementations/Devices/DeviceManager.cs
  16. 12 4
      Emby.Server.Implementations/Dto/DtoService.cs
  17. 1 0
      Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
  18. 2 1
      Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs
  19. 0 77
      Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs
  20. 5 23
      Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs
  21. 0 1
      Emby.Server.Implementations/HttpServer/ResponseFilter.cs
  22. 36 29
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  23. 78 37
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  24. 1 1
      Emby.Server.Implementations/HttpServer/Security/SessionContext.cs
  25. 6 4
      Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
  26. 7 2
      Emby.Server.Implementations/Library/LibraryManager.cs
  27. 23 24
      Emby.Server.Implementations/Library/MediaSourceManager.cs
  28. 1 1
      Emby.Server.Implementations/Library/MediaStreamSelector.cs
  29. 7 11
      Emby.Server.Implementations/Library/MusicManager.cs
  30. 3 0
      Emby.Server.Implementations/Library/SearchEngine.cs
  31. 2 0
      Emby.Server.Implementations/Library/UserDataManager.cs
  32. 0 1107
      Emby.Server.Implementations/Library/UserManager.cs
  33. 20 10
      Emby.Server.Implementations/Library/UserViewManager.cs
  34. 12 13
      Emby.Server.Implementations/LiveTv/LiveTvManager.cs
  35. 2 1
      Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs
  36. 3 0
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  37. 52 30
      Emby.Server.Implementations/Session/SessionManager.cs
  38. 1 0
      Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
  39. 1 0
      Emby.Server.Implementations/Sorting/DatePlayedComparer.cs
  40. 1 0
      Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs
  41. 1 0
      Emby.Server.Implementations/Sorting/IsPlayedComparer.cs
  42. 1 0
      Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs
  43. 1 0
      Emby.Server.Implementations/Sorting/PlayCountComparer.cs
  44. 27 52
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  45. 9 5
      Emby.Server.Implementations/TV/TVSeriesManager.cs
  46. 102 0
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  47. 15 11
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  48. 42 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  49. 11 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
  50. 18 4
      Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
  51. 42 0
      Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs
  52. 11 0
      Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs
  53. 44 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  54. 11 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
  55. 24 2
      Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
  56. 38 0
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  57. 15 0
      Jellyfin.Api/Constants/Policies.cs
  58. 0 1
      Jellyfin.Api/Controllers/ActivityLogController.cs
  59. 10 12
      Jellyfin.Api/Controllers/ConfigurationController.cs
  60. 9 11
      Jellyfin.Api/Controllers/DevicesController.cs
  61. 220 0
      Jellyfin.Api/Controllers/FilterController.cs
  62. 229 0
      Jellyfin.Api/Controllers/ImageByNameController.cs
  63. 88 0
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  64. 341 0
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  65. 76 0
      Jellyfin.Api/Controllers/LocalizationController.cs
  66. 17 14
      Jellyfin.Api/Controllers/NotificationsController.cs
  67. 9 10
      Jellyfin.Api/Controllers/PackageController.cs
  68. 188 0
      Jellyfin.Api/Controllers/PluginsController.cs
  69. 265 0
      Jellyfin.Api/Controllers/RemoteImageController.cs
  70. 2 1
      Jellyfin.Api/Controllers/SearchController.cs
  71. 25 19
      Jellyfin.Api/Controllers/StartupController.cs
  72. 348 0
      Jellyfin.Api/Controllers/SubtitleController.cs
  73. 2 3
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  74. 77 0
      Jellyfin.Api/Helpers/ClaimHelpers.cs
  75. 0 2
      Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
  76. 0 2
      Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs
  77. 0 2
      Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs
  78. 0 2
      Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs
  79. 40 0
      Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs
  80. 18 0
      Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs
  81. 3 5
      Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
  82. 2 4
      Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
  83. 65 0
      Jellyfin.Data/DayOfWeekHelper.cs
  84. 91 0
      Jellyfin.Data/Entities/AccessSchedule.cs
  85. 58 45
      Jellyfin.Data/Entities/Group.cs
  86. 30 0
      Jellyfin.Data/Entities/ImageInfo.cs
  87. 45 92
      Jellyfin.Data/Entities/Permission.cs
  88. 44 56
      Jellyfin.Data/Entities/Preference.cs
  89. 0 6
      Jellyfin.Data/Entities/ProviderMapping.cs
  90. 6 14
      Jellyfin.Data/Entities/Series.cs
  91. 386 114
      Jellyfin.Data/Entities/User.cs
  92. 1 1
      Jellyfin.Data/Enums/DynamicDayOfWeek.cs
  93. 107 20
      Jellyfin.Data/Enums/PermissionKind.cs
  94. 62 7
      Jellyfin.Data/Enums/PreferenceKind.cs
  95. 2 2
      Jellyfin.Data/Enums/SubtitlePlaybackMode.cs
  96. 4 4
      Jellyfin.Data/Enums/SyncPlayAccess.cs
  97. 1 1
      Jellyfin.Data/Enums/UnratedItem.cs
  98. 0 13
      Jellyfin.Data/Enums/Weekday.cs
  99. 31 0
      Jellyfin.Data/IHasPermissions.cs
  100. 1 0
      Jellyfin.Data/Jellyfin.Data.csproj

+ 2 - 0
CONTRIBUTORS.md

@@ -7,6 +7,7 @@
  - [anthonylavado](https://github.com/anthonylavado)
  - [Artiume](https://github.com/Artiume)
  - [AThomsen](https://github.com/AThomsen)
+ - [barronpm](https://github.com/barronpm)
  - [bilde2910](https://github.com/bilde2910)
  - [bfayers](https://github.com/bfayers)
  - [BnMcG](https://github.com/BnMcG)
@@ -130,6 +131,7 @@
  - [XVicarious](https://github.com/XVicarious)
  - [YouKnowBlom](https://github.com/YouKnowBlom)
  - [KristupasSavickas](https://github.com/KristupasSavickas)
+ - [Pusta](https://github.com/pusta)
 
 # Emby Contributors
 

+ 5 - 3
Emby.Dlna/ContentDirectory/ContentDirectory.cs

@@ -4,11 +4,12 @@ using System;
 using System.Linq;
 using System.Threading.Tasks;
 using Emby.Dlna.Service;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.TV;
@@ -32,7 +33,8 @@ namespace Emby.Dlna.ContentDirectory
         private readonly IMediaEncoder _mediaEncoder;
         private readonly ITVSeriesManager _tvSeriesManager;
 
-        public ContentDirectory(IDlnaManager dlna,
+        public ContentDirectory(
+            IDlnaManager dlna,
             IUserDataManager userDataManager,
             IImageProcessor imageProcessor,
             ILibraryManager libraryManager,
@@ -131,7 +133,7 @@ namespace Emby.Dlna.ContentDirectory
 
             foreach (var user in _userManager.Users)
             {
-                if (user.Policy.IsAdministrator)
+                if (user.HasPermission(PermissionKind.IsAdministrator))
                 {
                     return user;
                 }

+ 20 - 13
Emby.Dlna/ContentDirectory/ControlHandler.cs

@@ -10,6 +10,7 @@ using System.Threading;
 using System.Xml;
 using Emby.Dlna.Didl;
 using Emby.Dlna.Service;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Drawing;
@@ -17,7 +18,6 @@ using MediaBrowser.Controller.Dto;
 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.LiveTv;
 using MediaBrowser.Controller.MediaEncoding;
@@ -28,6 +28,12 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Querying;
 using Microsoft.Extensions.Logging;
+using Book = MediaBrowser.Controller.Entities.Book;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+using Genre = MediaBrowser.Controller.Entities.Genre;
+using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
+using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
+using Series = MediaBrowser.Controller.Entities.TV.Series;
 
 namespace Emby.Dlna.ContentDirectory
 {
@@ -731,7 +737,7 @@ namespace Emby.Dlna.ContentDirectory
                 return GetGenres(item, user, query);
             }
 
-            var array = new ServerItem[]
+            var array = new[]
             {
                 new ServerItem(item)
                 {
@@ -1115,7 +1121,7 @@ namespace Emby.Dlna.ContentDirectory
         private QueryResult<ServerItem> GetMusicPlaylists(User user, InternalItemsQuery query)
         {
             query.Parent = null;
-            query.IncludeItemTypes = new[] { typeof(Playlist).Name };
+            query.IncludeItemTypes = new[] { nameof(Playlist) };
             query.SetUser(user);
             query.Recursive = true;
 
@@ -1132,10 +1138,9 @@ namespace Emby.Dlna.ContentDirectory
             {
                 UserId = user.Id,
                 Limit = 50,
-                IncludeItemTypes = new[] { typeof(Audio).Name },
-                ParentId = parent == null ? Guid.Empty : parent.Id,
+                IncludeItemTypes = new[] { nameof(Audio) },
+                ParentId = parent?.Id ?? Guid.Empty,
                 GroupItems = true
-
             }, query.DtoOptions).Select(i => i.Item1 ?? i.Item2.FirstOrDefault()).Where(i => i != null).ToArray();
 
             return ToResult(items);
@@ -1150,7 +1155,6 @@ namespace Emby.Dlna.ContentDirectory
                 Limit = query.Limit,
                 StartIndex = query.StartIndex,
                 UserId = query.User.Id
-
             }, new[] { parent }, query.DtoOptions);
 
             return ToResult(result);
@@ -1167,7 +1171,6 @@ namespace Emby.Dlna.ContentDirectory
                 IncludeItemTypes = new[] { typeof(Episode).Name },
                 ParentId = parent == null ? Guid.Empty : parent.Id,
                 GroupItems = false
-
             }, query.DtoOptions).Select(i => i.Item1 ?? i.Item2.FirstOrDefault()).Where(i => i != null).ToArray();
 
             return ToResult(items);
@@ -1177,14 +1180,14 @@ namespace Emby.Dlna.ContentDirectory
         {
             query.OrderBy = Array.Empty<(string, SortOrder)>();
 
-            var items = _userViewManager.GetLatestItems(new LatestItemsQuery
+            var items = _userViewManager.GetLatestItems(
+                new LatestItemsQuery
             {
                 UserId = user.Id,
                 Limit = 50,
-                IncludeItemTypes = new[] { typeof(Movie).Name },
-                ParentId = parent == null ? Guid.Empty : parent.Id,
+                IncludeItemTypes = new[] { nameof(Movie) },
+                ParentId = parent?.Id ?? Guid.Empty,
                 GroupItems = true
-
             }, query.DtoOptions).Select(i => i.Item1 ?? i.Item2.FirstOrDefault()).Where(i => i != null).ToArray();
 
             return ToResult(items);
@@ -1217,7 +1220,11 @@ namespace Emby.Dlna.ContentDirectory
                 Recursive = true,
                 ParentId = parentId,
                 GenreIds = new[] { item.Id },
-                IncludeItemTypes = new[] { typeof(Movie).Name, typeof(Series).Name },
+                IncludeItemTypes = new[]
+                {
+                    nameof(Movie),
+                    nameof(Series)
+                },
                 Limit = limit,
                 StartIndex = startIndex,
                 DtoOptions = GetDtoOptions()

+ 9 - 5
Emby.Dlna/Didl/DidlBuilder.cs

@@ -6,14 +6,13 @@ using System.IO;
 using System.Linq;
 using System.Text;
 using System.Xml;
-using Emby.Dlna.Configuration;
 using Emby.Dlna.ContentDirectory;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Drawing;
 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.MediaEncoding;
 using MediaBrowser.Controller.Playlists;
@@ -23,6 +22,13 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Net;
 using Microsoft.Extensions.Logging;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+using Genre = MediaBrowser.Controller.Entities.Genre;
+using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
+using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
+using Season = MediaBrowser.Controller.Entities.TV.Season;
+using Series = MediaBrowser.Controller.Entities.TV.Series;
+using XmlAttribute = MediaBrowser.Model.Dlna.XmlAttribute;
 
 namespace Emby.Dlna.Didl
 {
@@ -421,7 +427,6 @@ namespace Emby.Dlna.Didl
                     case StubType.FavoriteSeries: return _localization.GetLocalizedString("HeaderFavoriteShows");
                     case StubType.FavoriteEpisodes: return _localization.GetLocalizedString("HeaderFavoriteEpisodes");
                     case StubType.Series: return _localization.GetLocalizedString("Shows");
-                    default: break;
                 }
             }
 
@@ -670,7 +675,7 @@ namespace Emby.Dlna.Didl
                 return;
             }
 
-            MediaBrowser.Model.Dlna.XmlAttribute secAttribute = null;
+            XmlAttribute secAttribute = null;
             foreach (var attribute in _profile.XmlRootAttributes)
             {
                 if (string.Equals(attribute.Name, "xmlns:sec", StringComparison.OrdinalIgnoreCase))
@@ -995,7 +1000,6 @@ namespace Emby.Dlna.Didl
             }
 
             AddImageResElement(item, writer, 160, 160, "jpg", "JPEG_TN");
-
         }
 
         private void AddImageResElement(

+ 9 - 1
Emby.Dlna/PlayTo/PlayToController.cs

@@ -7,6 +7,7 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using Emby.Dlna.Didl;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
@@ -22,6 +23,7 @@ using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Session;
 using Microsoft.AspNetCore.WebUtilities;
 using Microsoft.Extensions.Logging;
+using Photo = MediaBrowser.Controller.Entities.Photo;
 
 namespace Emby.Dlna.PlayTo
 {
@@ -446,7 +448,13 @@ namespace Emby.Dlna.PlayTo
             }
         }
 
-        private PlaylistItem CreatePlaylistItem(BaseItem item, User user, long startPostionTicks, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
+        private PlaylistItem CreatePlaylistItem(
+            BaseItem item,
+            User user,
+            long startPostionTicks,
+            string mediaSourceId,
+            int? audioStreamIndex,
+            int? subtitleStreamIndex)
         {
             var deviceInfo = _device.Properties;
 

+ 9 - 0
Emby.Drawing/ImageProcessor.cs

@@ -4,6 +4,7 @@ using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Drawing;
@@ -14,6 +15,7 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Net;
 using Microsoft.Extensions.Logging;
+using Photo = MediaBrowser.Controller.Entities.Photo;
 
 namespace Emby.Drawing
 {
@@ -349,6 +351,13 @@ namespace Emby.Drawing
             });
         }
 
+        /// <inheritdoc />
+        public string GetImageCacheTag(User user)
+        {
+            return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
+                .ToString("N", CultureInfo.InvariantCulture);
+        }
+
         private async Task<(string path, DateTime dateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
         {
             var inputFormat = Path.GetExtension(originalImagePath)

+ 5 - 3
Emby.Notifications/NotificationManager.cs

@@ -4,6 +4,8 @@ using System.Globalization;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
@@ -101,7 +103,7 @@ namespace Emby.Notifications
                 switch (request.SendToUserMode.Value)
                 {
                     case SendToUserType.Admins:
-                        return _userManager.Users.Where(i => i.Policy.IsAdministrator)
+                        return _userManager.Users.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
                                 .Select(i => i.Id);
                     case SendToUserType.All:
                         return _userManager.UsersIds;
@@ -117,7 +119,7 @@ namespace Emby.Notifications
                 var config = GetConfiguration();
 
                 return _userManager.Users
-                    .Where(i => config.IsEnabledToSendToUser(request.NotificationType, i.Id.ToString("N", CultureInfo.InvariantCulture), i.Policy))
+                    .Where(i => config.IsEnabledToSendToUser(request.NotificationType, i.Id.ToString("N", CultureInfo.InvariantCulture), i))
                     .Select(i => i.Id);
             }
 
@@ -142,7 +144,7 @@ namespace Emby.Notifications
                 User = user
             };
 
-            _logger.LogDebug("Sending notification via {0} to user {1}", service.Name, user.Name);
+            _logger.LogDebug("Sending notification via {0} to user {1}", service.Name, user.Username);
 
             try
             {

+ 22 - 34
Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs

@@ -88,25 +88,26 @@ namespace Emby.Server.Implementations.Activity
 
             _subManager.SubtitleDownloadFailure += OnSubtitleDownloadFailure;
 
-            _userManager.UserCreated += OnUserCreated;
-            _userManager.UserPasswordChanged += OnUserPasswordChanged;
-            _userManager.UserDeleted += OnUserDeleted;
-            _userManager.UserPolicyUpdated += OnUserPolicyUpdated;
-            _userManager.UserLockedOut += OnUserLockedOut;
+            _userManager.OnUserCreated += OnUserCreated;
+            _userManager.OnUserPasswordChanged += OnUserPasswordChanged;
+            _userManager.OnUserDeleted += OnUserDeleted;
+            _userManager.OnUserLockedOut += OnUserLockedOut;
 
             return Task.CompletedTask;
         }
 
-        private async void OnUserLockedOut(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
+        private async void OnUserLockedOut(object sender, GenericEventArgs<User> e)
         {
             await CreateLogEntry(new ActivityLog(
                     string.Format(
                         CultureInfo.InvariantCulture,
                         _localization.GetLocalizedString("UserLockedOutWithName"),
-                        e.Argument.Name),
+                        e.Argument.Username),
                     NotificationType.UserLockedOut.ToString(),
-                    e.Argument.Id))
-                .ConfigureAwait(false);
+                    e.Argument.Id)
+            {
+                LogSeverity = LogLevel.Error
+            }).ConfigureAwait(false);
         }
 
         private async void OnSubtitleDownloadFailure(object sender, SubtitleDownloadFailureEventArgs e)
@@ -152,7 +153,7 @@ namespace Emby.Server.Implementations.Activity
                 string.Format(
                     CultureInfo.InvariantCulture,
                     _localization.GetLocalizedString("UserStoppedPlayingItemWithValues"),
-                    user.Name,
+                    user.Username,
                     GetItemName(item),
                     e.DeviceName),
                 GetPlaybackStoppedNotificationType(item.MediaType),
@@ -187,7 +188,7 @@ namespace Emby.Server.Implementations.Activity
                 string.Format(
                     CultureInfo.InvariantCulture,
                     _localization.GetLocalizedString("UserStartedPlayingItemWithValues"),
-                    user.Name,
+                    user.Username,
                     GetItemName(item),
                     e.DeviceName),
                 GetPlaybackNotificationType(item.MediaType),
@@ -304,49 +305,37 @@ namespace Emby.Server.Implementations.Activity
             }).ConfigureAwait(false);
         }
 
-        private async void OnUserPolicyUpdated(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
-        {
-            await CreateLogEntry(new ActivityLog(
-                string.Format(
-                    CultureInfo.InvariantCulture,
-                    _localization.GetLocalizedString("UserPolicyUpdatedWithName"),
-                    e.Argument.Name),
-                "UserPolicyUpdated",
-                e.Argument.Id))
-                .ConfigureAwait(false);
-        }
-
-        private async void OnUserDeleted(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
+        private async void OnUserDeleted(object sender, GenericEventArgs<User> e)
         {
             await CreateLogEntry(new ActivityLog(
                 string.Format(
                     CultureInfo.InvariantCulture,
                     _localization.GetLocalizedString("UserDeletedWithName"),
-                    e.Argument.Name),
+                    e.Argument.Username),
                 "UserDeleted",
                 Guid.Empty))
                 .ConfigureAwait(false);
         }
 
-        private async void OnUserPasswordChanged(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
+        private async void OnUserPasswordChanged(object sender, GenericEventArgs<User> e)
         {
             await CreateLogEntry(new ActivityLog(
                 string.Format(
                     CultureInfo.InvariantCulture,
                     _localization.GetLocalizedString("UserPasswordChangedWithName"),
-                    e.Argument.Name),
+                    e.Argument.Username),
                 "UserPasswordChanged",
                 e.Argument.Id))
                 .ConfigureAwait(false);
         }
 
-        private async void OnUserCreated(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
+        private async void OnUserCreated(object sender, GenericEventArgs<User> e)
         {
             await CreateLogEntry(new ActivityLog(
                 string.Format(
                     CultureInfo.InvariantCulture,
                     _localization.GetLocalizedString("UserCreatedWithName"),
-                    e.Argument.Name),
+                    e.Argument.Username),
                 "UserCreated",
                 e.Argument.Id))
                 .ConfigureAwait(false);
@@ -510,11 +499,10 @@ namespace Emby.Server.Implementations.Activity
 
             _subManager.SubtitleDownloadFailure -= OnSubtitleDownloadFailure;
 
-            _userManager.UserCreated -= OnUserCreated;
-            _userManager.UserPasswordChanged -= OnUserPasswordChanged;
-            _userManager.UserDeleted -= OnUserDeleted;
-            _userManager.UserPolicyUpdated -= OnUserPolicyUpdated;
-            _userManager.UserLockedOut -= OnUserLockedOut;
+            _userManager.OnUserCreated -= OnUserCreated;
+            _userManager.OnUserPasswordChanged -= OnUserPasswordChanged;
+            _userManager.OnUserDeleted -= OnUserDeleted;
+            _userManager.OnUserLockedOut -= OnUserLockedOut;
         }
 
         /// <summary>

+ 1 - 9
Emby.Server.Implementations/ApplicationHost.cs

@@ -562,11 +562,8 @@ namespace Emby.Server.Implementations
 
             serviceCollection.AddSingleton<IAuthenticationRepository, AuthenticationRepository>();
 
-            serviceCollection.AddSingleton<IUserRepository, SqliteUserRepository>();
-
             // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required
             serviceCollection.AddTransient(provider => new Lazy<IDtoService>(provider.GetRequiredService<IDtoService>));
-            serviceCollection.AddSingleton<IUserManager, UserManager>();
 
             // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required
             // TODO: Add StartupOptions.FFmpegPath to IConfiguration and remove this custom activation
@@ -659,15 +656,11 @@ namespace Emby.Server.Implementations
 
             ((SqliteDisplayPreferencesRepository)Resolve<IDisplayPreferencesRepository>()).Initialize();
             ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize();
-            ((SqliteUserRepository)Resolve<IUserRepository>()).Initialize();
 
             SetStaticProperties();
 
-            var userManager = (UserManager)Resolve<IUserManager>();
-            userManager.Initialize();
-
             var userDataRepo = (SqliteUserDataRepository)Resolve<IUserDataRepository>();
-            ((SqliteItemRepository)Resolve<IItemRepository>()).Initialize(userDataRepo, userManager);
+            ((SqliteItemRepository)Resolve<IItemRepository>()).Initialize(userDataRepo, Resolve<IUserManager>());
 
             FindParts();
         }
@@ -750,7 +743,6 @@ namespace Emby.Server.Implementations
             BaseItem.ProviderManager = Resolve<IProviderManager>();
             BaseItem.LocalizationManager = Resolve<ILocalizationManager>();
             BaseItem.ItemRepository = Resolve<IItemRepository>();
-            User.UserManager = Resolve<IUserManager>();
             BaseItem.FileSystem = _fileSystemManager;
             BaseItem.UserDataManager = Resolve<IUserDataManager>();
             BaseItem.ChannelManager = Resolve<IChannelManager>();

+ 8 - 3
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -6,6 +6,7 @@ using System.IO;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller.Channels;
@@ -13,8 +14,6 @@ using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dto;
 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.Providers;
 using MediaBrowser.Model.Channels;
@@ -24,6 +23,11 @@ using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Serialization;
 using Microsoft.Extensions.Logging;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
+using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
+using Season = MediaBrowser.Controller.Entities.TV.Season;
+using Series = MediaBrowser.Controller.Entities.TV.Series;
 
 namespace Emby.Server.Implementations.Channels
 {
@@ -791,7 +795,8 @@ namespace Emby.Server.Implementations.Channels
             return result;
         }
 
-        private async Task<ChannelItemResult> GetChannelItems(IChannel channel,
+        private async Task<ChannelItemResult> GetChannelItems(
+            IChannel channel,
             User user,
             string externalFolderId,
             ChannelItemSortField? sortField,

+ 1 - 0
Emby.Server.Implementations/Collections/CollectionManager.cs

@@ -5,6 +5,7 @@ using System.IO;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Collections;
 using MediaBrowser.Controller.Configuration;

+ 1 - 1
Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs

@@ -1,4 +1,4 @@
-#pragma warning disable CS1591
+#pragma warning disable CS1591
 
 using System;
 using System.Collections.Generic;

+ 1 - 0
Emby.Server.Implementations/Data/SqliteUserDataRepository.cs

@@ -4,6 +4,7 @@ using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Threading;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;

+ 0 - 240
Emby.Server.Implementations/Data/SqliteUserRepository.cs

@@ -1,240 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Text.Json;
-using MediaBrowser.Common.Json;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Persistence;
-using Microsoft.Extensions.Logging;
-using SQLitePCL.pretty;
-
-namespace Emby.Server.Implementations.Data
-{
-    /// <summary>
-    /// Class SQLiteUserRepository
-    /// </summary>
-    public class SqliteUserRepository : BaseSqliteRepository, IUserRepository
-    {
-        private readonly JsonSerializerOptions _jsonOptions;
-
-        public SqliteUserRepository(
-            ILogger<SqliteUserRepository> logger,
-            IServerApplicationPaths appPaths)
-            : base(logger)
-        {
-            _jsonOptions = JsonDefaults.GetOptions();
-
-            DbFilePath = Path.Combine(appPaths.DataPath, "users.db");
-        }
-
-        /// <summary>
-        /// Gets the name of the repository
-        /// </summary>
-        /// <value>The name.</value>
-        public string Name => "SQLite";
-
-        /// <summary>
-        /// Opens the connection to the database.
-        /// </summary>
-        public void Initialize()
-        {
-            using (var connection = GetConnection())
-            {
-                var localUsersTableExists = TableExists(connection, "LocalUsersv2");
-
-                connection.RunQueries(new[] {
-                    "create table if not exists LocalUsersv2 (Id INTEGER PRIMARY KEY, guid GUID NOT NULL, data BLOB NOT NULL)",
-                    "drop index if exists idx_users"
-                });
-
-                if (!localUsersTableExists && TableExists(connection, "Users"))
-                {
-                    TryMigrateToLocalUsersTable(connection);
-                }
-
-                RemoveEmptyPasswordHashes(connection);
-            }
-        }
-
-        private void TryMigrateToLocalUsersTable(ManagedConnection connection)
-        {
-            try
-            {
-                connection.RunQueries(new[]
-                {
-                    "INSERT INTO LocalUsersv2 (guid, data) SELECT guid,data from users"
-                });
-            }
-            catch (Exception ex)
-            {
-                Logger.LogError(ex, "Error migrating users database");
-            }
-        }
-
-        private void RemoveEmptyPasswordHashes(ManagedConnection connection)
-        {
-            foreach (var user in RetrieveAllUsers(connection))
-            {
-                // If the user password is the sha1 hash of the empty string, remove it
-                if (!string.Equals(user.Password, "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", StringComparison.Ordinal)
-                    && !string.Equals(user.Password, "$SHA1$DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", StringComparison.Ordinal))
-                {
-                    continue;
-                }
-
-                user.Password = null;
-                var serialized = JsonSerializer.SerializeToUtf8Bytes(user, _jsonOptions);
-
-                connection.RunInTransaction(db =>
-                {
-                    using (var statement = db.PrepareStatement("update LocalUsersv2 set data=@data where Id=@InternalId"))
-                    {
-                        statement.TryBind("@InternalId", user.InternalId);
-                        statement.TryBind("@data", serialized);
-                        statement.MoveNext();
-                    }
-                }, TransactionMode);
-            }
-        }
-
-        /// <summary>
-        /// Save a user in the repo
-        /// </summary>
-        public void CreateUser(User user)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            var serialized = JsonSerializer.SerializeToUtf8Bytes(user, _jsonOptions);
-
-            using (var connection = GetConnection())
-            {
-                connection.RunInTransaction(db =>
-                {
-                    using (var statement = db.PrepareStatement("insert into LocalUsersv2 (guid, data) values (@guid, @data)"))
-                    {
-                        statement.TryBind("@guid", user.Id.ToByteArray());
-                        statement.TryBind("@data", serialized);
-
-                        statement.MoveNext();
-                    }
-
-                    var createdUser = GetUser(user.Id, connection);
-
-                    if (createdUser == null)
-                    {
-                        throw new ApplicationException("created user should never be null");
-                    }
-
-                    user.InternalId = createdUser.InternalId;
-
-                }, TransactionMode);
-            }
-        }
-
-        public void UpdateUser(User user)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            var serialized = JsonSerializer.SerializeToUtf8Bytes(user, _jsonOptions);
-
-            using (var connection = GetConnection())
-            {
-                connection.RunInTransaction(db =>
-                {
-                    using (var statement = db.PrepareStatement("update LocalUsersv2 set data=@data where Id=@InternalId"))
-                    {
-                        statement.TryBind("@InternalId", user.InternalId);
-                        statement.TryBind("@data", serialized);
-                        statement.MoveNext();
-                    }
-
-                }, TransactionMode);
-            }
-        }
-
-        private User GetUser(Guid guid, ManagedConnection connection)
-        {
-            using (var statement = connection.PrepareStatement("select id,guid,data from LocalUsersv2 where guid=@guid"))
-            {
-                statement.TryBind("@guid", guid);
-
-                foreach (var row in statement.ExecuteQuery())
-                {
-                    return GetUser(row);
-                }
-            }
-
-            return null;
-        }
-
-        private User GetUser(IReadOnlyList<IResultSetValue> row)
-        {
-            var id = row[0].ToInt64();
-            var guid = row[1].ReadGuidFromBlob();
-
-            var user = JsonSerializer.Deserialize<User>(row[2].ToBlob(), _jsonOptions);
-            user.InternalId = id;
-            user.Id = guid;
-            return user;
-        }
-
-        /// <summary>
-        /// Retrieve all users from the database
-        /// </summary>
-        /// <returns>IEnumerable{User}.</returns>
-        public List<User> RetrieveAllUsers()
-        {
-            using (var connection = GetConnection(true))
-            {
-                return new List<User>(RetrieveAllUsers(connection));
-            }
-        }
-
-        /// <summary>
-        /// Retrieve all users from the database
-        /// </summary>
-        /// <returns>IEnumerable{User}.</returns>
-        private IEnumerable<User> RetrieveAllUsers(ManagedConnection connection)
-        {
-            foreach (var row in connection.Query("select id,guid,data from LocalUsersv2"))
-            {
-                yield return GetUser(row);
-            }
-        }
-
-        /// <summary>
-        /// Deletes the user.
-        /// </summary>
-        /// <param name="user">The user.</param>
-        /// <returns>Task.</returns>
-        /// <exception cref="ArgumentNullException">user</exception>
-        public void DeleteUser(User user)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            using (var connection = GetConnection())
-            {
-                connection.RunInTransaction(db =>
-                {
-                    using (var statement = db.PrepareStatement("delete from LocalUsersv2 where Id=@id"))
-                    {
-                        statement.TryBind("@id", user.InternalId);
-                        statement.MoveNext();
-                    }
-                }, TransactionMode);
-            }
-        }
-    }
-}

+ 9 - 20
Emby.Server.Implementations/Devices/DeviceManager.cs

@@ -5,10 +5,11 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Linq;
+using Jellyfin.Data.Enums;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Devices;
-using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Model.Devices;
@@ -16,7 +17,6 @@ using MediaBrowser.Model.Events;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Session;
-using MediaBrowser.Model.Users;
 
 namespace Emby.Server.Implementations.Devices
 {
@@ -27,11 +27,10 @@ namespace Emby.Server.Implementations.Devices
         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;
 
-        private readonly object _capabilitiesSyncLock = new object();
-
         public DeviceManager(
             IAuthenticationRepository authRepo,
             IJsonSerializer json,
@@ -175,7 +174,12 @@ namespace Emby.Server.Implementations.Devices
                 throw new ArgumentNullException(nameof(deviceId));
             }
 
-            if (!CanAccessDevice(user.Policy, deviceId))
+            if (user.HasPermission(PermissionKind.EnableAllDevices) || user.HasPermission(PermissionKind.IsAdministrator))
+            {
+                return true;
+            }
+
+            if (!user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparer.OrdinalIgnoreCase))
             {
                 var capabilities = GetCapabilities(deviceId);
 
@@ -187,20 +191,5 @@ namespace Emby.Server.Implementations.Devices
 
             return true;
         }
-
-        private static bool CanAccessDevice(UserPolicy policy, string id)
-        {
-            if (policy.EnableAllDevices)
-            {
-                return true;
-            }
-
-            if (policy.IsAdministrator)
-            {
-                return true;
-            }
-
-            return policy.EnabledDevices.Contains(id, StringComparer.OrdinalIgnoreCase);
-        }
     }
 }

+ 12 - 4
Emby.Server.Implementations/Dto/DtoService.cs

@@ -6,14 +6,14 @@ using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;
 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.LiveTv;
 using MediaBrowser.Controller.Persistence;
@@ -24,6 +24,14 @@ using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using Microsoft.Extensions.Logging;
+using Book = MediaBrowser.Controller.Entities.Book;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
+using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
+using Person = MediaBrowser.Controller.Entities.Person;
+using Photo = MediaBrowser.Controller.Entities.Photo;
+using Season = MediaBrowser.Controller.Entities.TV.Season;
+using Series = MediaBrowser.Controller.Entities.TV.Series;
 
 namespace Emby.Server.Implementations.Dto
 {
@@ -384,7 +392,7 @@ namespace Emby.Server.Implementations.Dto
 
                     if (options.ContainsField(ItemFields.ChildCount))
                     {
-                        dto.ChildCount = dto.ChildCount ?? GetChildCount(folder, user);
+                        dto.ChildCount ??= GetChildCount(folder, user);
                     }
                 }
 
@@ -414,7 +422,7 @@ namespace Emby.Server.Implementations.Dto
 
             if (options.ContainsField(ItemFields.BasicSyncInfo))
             {
-                var userCanSync = user != null && user.Policy.EnableContentDownloading;
+                var userCanSync = user != null && user.HasPermission(PermissionKind.EnableContentDownloading);
                 if (userCanSync && item.SupportsExternalTransfer)
                 {
                     dto.SupportsSync = true;

+ 1 - 0
Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs

@@ -6,6 +6,7 @@ using System.Globalization;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;

+ 2 - 1
Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs

@@ -4,6 +4,7 @@ using System;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.Plugins;
@@ -64,7 +65,7 @@ namespace Emby.Server.Implementations.EntryPoints
 
         private async Task SendMessage(string name, TimerEventInfo info)
         {
-            var users = _userManager.Users.Where(i => i.Policy.EnableLiveTvAccess).Select(i => i.Id).ToList();
+            var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList();
 
             try
             {

+ 0 - 77
Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs

@@ -1,77 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Tasks;
-
-namespace Emby.Server.Implementations.EntryPoints
-{
-    /// <summary>
-    /// Class RefreshUsersMetadata.
-    /// </summary>
-    public class RefreshUsersMetadata : IScheduledTask, IConfigurableScheduledTask
-    {
-        /// <summary>
-        /// The user manager.
-        /// </summary>
-        private readonly IUserManager _userManager;
-        private readonly IFileSystem _fileSystem;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="RefreshUsersMetadata" /> class.
-        /// </summary>
-        public RefreshUsersMetadata(IUserManager userManager, IFileSystem fileSystem)
-        {
-            _userManager = userManager;
-            _fileSystem = fileSystem;
-        }
-
-        /// <inheritdoc />
-        public string Name => "Refresh Users";
-
-        /// <inheritdoc />
-        public string Key => "RefreshUsers";
-
-        /// <inheritdoc />
-        public string Description => "Refresh user infos";
-
-        /// <inheritdoc />
-        public string Category => "Library";
-
-        /// <inheritdoc />
-        public bool IsHidden => true;
-
-        /// <inheritdoc />
-        public bool IsEnabled => true;
-
-        /// <inheritdoc />
-        public bool IsLogged => true;
-
-        /// <inheritdoc />
-        public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
-        {
-            foreach (var user in _userManager.Users)
-            {
-                cancellationToken.ThrowIfCancellationRequested();
-
-                await user.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false);
-            }
-        }
-
-        /// <inheritdoc />
-        public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
-        {
-            return new[]
-            {
-                new TaskTriggerInfo
-                {
-                    IntervalTicks = TimeSpan.FromDays(1).Ticks,
-                    Type = TaskTriggerInfo.TriggerInterval
-                }
-            };
-        }
-    }
-}

+ 5 - 23
Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs

@@ -3,10 +3,10 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Common.Updates;
 using MediaBrowser.Controller;
-using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Plugins;
 using MediaBrowser.Controller.Session;
@@ -68,10 +68,8 @@ namespace Emby.Server.Implementations.EntryPoints
         /// <inheritdoc />
         public Task RunAsync()
         {
-            _userManager.UserDeleted += OnUserDeleted;
-            _userManager.UserUpdated += OnUserUpdated;
-            _userManager.UserPolicyUpdated += OnUserPolicyUpdated;
-            _userManager.UserConfigurationUpdated += OnUserConfigurationUpdated;
+            _userManager.OnUserDeleted += OnUserDeleted;
+            _userManager.OnUserUpdated += OnUserUpdated;
 
             _appHost.HasPendingRestartChanged += OnHasPendingRestartChanged;
 
@@ -153,20 +151,6 @@ namespace Emby.Server.Implementations.EntryPoints
             await SendMessageToUserSession(e.Argument, "UserDeleted", e.Argument.Id.ToString("N", CultureInfo.InvariantCulture)).ConfigureAwait(false);
         }
 
-        private async void OnUserPolicyUpdated(object sender, GenericEventArgs<User> e)
-        {
-            var dto = _userManager.GetUserDto(e.Argument);
-
-            await SendMessageToUserSession(e.Argument, "UserPolicyUpdated", dto).ConfigureAwait(false);
-        }
-
-        private async void OnUserConfigurationUpdated(object sender, GenericEventArgs<User> e)
-        {
-            var dto = _userManager.GetUserDto(e.Argument);
-
-            await SendMessageToUserSession(e.Argument, "UserConfigurationUpdated", dto).ConfigureAwait(false);
-        }
-
         private async Task SendMessageToAdminSessions<T>(string name, T data)
         {
             try
@@ -210,10 +194,8 @@ namespace Emby.Server.Implementations.EntryPoints
         {
             if (dispose)
             {
-                _userManager.UserDeleted -= OnUserDeleted;
-                _userManager.UserUpdated -= OnUserUpdated;
-                _userManager.UserPolicyUpdated -= OnUserPolicyUpdated;
-                _userManager.UserConfigurationUpdated -= OnUserConfigurationUpdated;
+                _userManager.OnUserDeleted -= OnUserDeleted;
+                _userManager.OnUserUpdated -= OnUserUpdated;
 
                 _installationManager.PluginUninstalled -= OnPluginUninstalled;
                 _installationManager.PackageInstalling -= OnPackageInstalling;

+ 0 - 1
Emby.Server.Implementations/HttpServer/ResponseFilter.cs

@@ -1,5 +1,4 @@
 using System;
-using System.Collections.Generic;
 using System.Globalization;
 using System.Text;
 using MediaBrowser.Controller.Net;

+ 36 - 29
Emby.Server.Implementations/HttpServer/Security/AuthService.cs

@@ -3,10 +3,11 @@
 using System;
 using System.Linq;
 using Emby.Server.Implementations.SocketSharp;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Controller.Session;
@@ -38,9 +39,9 @@ namespace Emby.Server.Implementations.HttpServer.Security
             _networkManager = networkManager;
         }
 
-        public void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues)
+        public void Authenticate(IRequest request, IAuthenticationAttributes authAttributes)
         {
-            ValidateUser(request, authAttribtues);
+            ValidateUser(request, authAttributes);
         }
 
         public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes)
@@ -50,17 +51,33 @@ namespace Emby.Server.Implementations.HttpServer.Security
             return user;
         }
 
-        private User ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues)
+        public AuthorizationInfo Authenticate(HttpRequest request)
+        {
+            var auth = _authorizationContext.GetAuthorizationInfo(request);
+            if (auth?.User == null)
+            {
+                return null;
+            }
+
+            if (auth.User.HasPermission(PermissionKind.IsDisabled))
+            {
+                throw new SecurityException("User account has been disabled.");
+            }
+
+            return auth;
+        }
+
+        private User ValidateUser(IRequest request, IAuthenticationAttributes authAttributes)
         {
             // This code is executed before the service
             var auth = _authorizationContext.GetAuthorizationInfo(request);
 
-            if (!IsExemptFromAuthenticationToken(authAttribtues, request))
+            if (!IsExemptFromAuthenticationToken(authAttributes, request))
             {
                 ValidateSecurityToken(request, auth.Token);
             }
 
-            if (authAttribtues.AllowLocalOnly && !request.IsLocal)
+            if (authAttributes.AllowLocalOnly && !request.IsLocal)
             {
                 throw new SecurityException("Operation not found.");
             }
@@ -74,14 +91,14 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
             if (user != null)
             {
-                ValidateUserAccess(user, request, authAttribtues, auth);
+                ValidateUserAccess(user, request, authAttributes);
             }
 
             var info = GetTokenInfo(request);
 
-            if (!IsExemptFromRoles(auth, authAttribtues, request, info))
+            if (!IsExemptFromRoles(auth, authAttributes, request, info))
             {
-                var roles = authAttribtues.GetRoles();
+                var roles = authAttributes.GetRoles();
 
                 ValidateRoles(roles, user);
             }
@@ -90,7 +107,8 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 !string.IsNullOrEmpty(auth.Client) &&
                 !string.IsNullOrEmpty(auth.Device))
             {
-                _sessionManager.LogSessionActivity(auth.Client,
+                _sessionManager.LogSessionActivity(
+                    auth.Client,
                     auth.Version,
                     auth.DeviceId,
                     auth.Device,
@@ -104,21 +122,20 @@ namespace Emby.Server.Implementations.HttpServer.Security
         private void ValidateUserAccess(
             User user,
             IRequest request,
-            IAuthenticationAttributes authAttribtues,
-            AuthorizationInfo auth)
+            IAuthenticationAttributes authAttributes)
         {
-            if (user.Policy.IsDisabled)
+            if (user.HasPermission(PermissionKind.IsDisabled))
             {
                 throw new SecurityException("User account has been disabled.");
             }
 
-            if (!user.Policy.EnableRemoteAccess && !_networkManager.IsInLocalNetwork(request.RemoteIp))
+            if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !_networkManager.IsInLocalNetwork(request.RemoteIp))
             {
                 throw new SecurityException("User account has been disabled.");
             }
 
-            if (!user.Policy.IsAdministrator
-                && !authAttribtues.EscapeParentalControl
+            if (!user.HasPermission(PermissionKind.IsAdministrator)
+                && !authAttributes.EscapeParentalControl
                 && !user.IsParentalScheduleAllowed())
             {
                 request.Response.Headers.Add("X-Application-Error-Code", "ParentalControl");
@@ -186,7 +203,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
         {
             if (roles.Contains("admin", StringComparer.OrdinalIgnoreCase))
             {
-                if (user == null || !user.Policy.IsAdministrator)
+                if (user == null || !user.HasPermission(PermissionKind.IsAdministrator))
                 {
                     throw new SecurityException("User does not have admin access.");
                 }
@@ -194,7 +211,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
             if (roles.Contains("delete", StringComparer.OrdinalIgnoreCase))
             {
-                if (user == null || !user.Policy.EnableContentDeletion)
+                if (user == null || !user.HasPermission(PermissionKind.EnableContentDeletion))
                 {
                     throw new SecurityException("User does not have delete access.");
                 }
@@ -202,7 +219,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
             if (roles.Contains("download", StringComparer.OrdinalIgnoreCase))
             {
-                if (user == null || !user.Policy.EnableContentDownloading)
+                if (user == null || !user.HasPermission(PermissionKind.EnableContentDownloading))
                 {
                     throw new SecurityException("User does not have download access.");
                 }
@@ -228,16 +245,6 @@ namespace Emby.Server.Implementations.HttpServer.Security
             {
                 throw new AuthenticationException("Access token is invalid or expired.");
             }
-
-            //if (!string.IsNullOrEmpty(info.UserId))
-            //{
-            //    var user = _userManager.GetUserById(info.UserId);
-
-            //    if (user == null || user.Configuration.IsDisabled)
-            //    {
-            //        throw new SecurityException("User account has been disabled.");
-            //    }
-            //}
         }
     }
 }

+ 78 - 37
Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs

@@ -8,6 +8,7 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;
 using MediaBrowser.Model.Services;
+using Microsoft.AspNetCore.Http;
 using Microsoft.Net.Http.Headers;
 
 namespace Emby.Server.Implementations.HttpServer.Security
@@ -38,6 +39,14 @@ namespace Emby.Server.Implementations.HttpServer.Security
             return GetAuthorization(requestContext);
         }
 
+        public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext)
+        {
+            var auth = GetAuthorizationDictionary(requestContext);
+            var (authInfo, _) =
+                GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
+            return authInfo;
+        }
+
         /// <summary>
         /// Gets the authorization.
         /// </summary>
@@ -46,7 +55,23 @@ namespace Emby.Server.Implementations.HttpServer.Security
         private AuthorizationInfo GetAuthorization(IRequest httpReq)
         {
             var auth = GetAuthorizationDictionary(httpReq);
+            var (authInfo, originalAuthInfo) =
+                GetAuthorizationInfoFromDictionary(auth, httpReq.Headers, httpReq.QueryString);
 
+            if (originalAuthInfo != null)
+            {
+                httpReq.Items["OriginalAuthenticationInfo"] = originalAuthInfo;
+            }
+
+            httpReq.Items["AuthorizationInfo"] = authInfo;
+            return authInfo;
+        }
+
+        private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary(
+            in Dictionary<string, string> auth,
+            in IHeaderDictionary headers,
+            in IQueryCollection queryString)
+        {
             string deviceId = null;
             string device = null;
             string client = null;
@@ -64,19 +89,26 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
             if (string.IsNullOrEmpty(token))
             {
-                token = httpReq.Headers["X-Emby-Token"];
+                token = headers["X-Emby-Token"];
             }
 
             if (string.IsNullOrEmpty(token))
             {
-                token = httpReq.Headers["X-MediaBrowser-Token"];
+                token = headers["X-MediaBrowser-Token"];
             }
+
+            if (string.IsNullOrEmpty(token))
+            {
+                token = queryString["ApiKey"];
+            }
+
+            // TODO deprecate this query parameter.
             if (string.IsNullOrEmpty(token))
             {
-                token = httpReq.QueryString["api_key"];
+                token = queryString["api_key"];
             }
 
-            var info = new AuthorizationInfo
+            var authInfo = new AuthorizationInfo
             {
                 Client = client,
                 Device = device,
@@ -85,6 +117,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
                 Token = token
             };
 
+            AuthenticationInfo originalAuthenticationInfo = null;
             if (!string.IsNullOrWhiteSpace(token))
             {
                 var result = _authRepo.Get(new AuthenticationInfoQuery
@@ -92,81 +125,77 @@ namespace Emby.Server.Implementations.HttpServer.Security
                     AccessToken = token
                 });
 
-                var tokenInfo = result.Items.Count > 0 ? result.Items[0] : null;
+                originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null;
 
-                if (tokenInfo != null)
+                if (originalAuthenticationInfo != null)
                 {
                     var updateToken = false;
 
                     // TODO: Remove these checks for IsNullOrWhiteSpace
-                    if (string.IsNullOrWhiteSpace(info.Client))
+                    if (string.IsNullOrWhiteSpace(authInfo.Client))
                     {
-                        info.Client = tokenInfo.AppName;
+                        authInfo.Client = originalAuthenticationInfo.AppName;
                     }
 
-                    if (string.IsNullOrWhiteSpace(info.DeviceId))
+                    if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
                     {
-                        info.DeviceId = tokenInfo.DeviceId;
+                        authInfo.DeviceId = originalAuthenticationInfo.DeviceId;
                     }
 
                     // Temporary. TODO - allow clients to specify that the token has been shared with a casting device
-                    var allowTokenInfoUpdate = info.Client == null || info.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
+                    var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
 
-                    if (string.IsNullOrWhiteSpace(info.Device))
+                    if (string.IsNullOrWhiteSpace(authInfo.Device))
                     {
-                        info.Device = tokenInfo.DeviceName;
+                        authInfo.Device = originalAuthenticationInfo.DeviceName;
                     }
-
-                    else if (!string.Equals(info.Device, tokenInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
+                    else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
                     {
                         if (allowTokenInfoUpdate)
                         {
                             updateToken = true;
-                            tokenInfo.DeviceName = info.Device;
+                            originalAuthenticationInfo.DeviceName = authInfo.Device;
                         }
                     }
 
-                    if (string.IsNullOrWhiteSpace(info.Version))
+                    if (string.IsNullOrWhiteSpace(authInfo.Version))
                     {
-                        info.Version = tokenInfo.AppVersion;
+                        authInfo.Version = originalAuthenticationInfo.AppVersion;
                     }
-                    else if (!string.Equals(info.Version, tokenInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
+                    else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
                     {
                         if (allowTokenInfoUpdate)
                         {
                             updateToken = true;
-                            tokenInfo.AppVersion = info.Version;
+                            originalAuthenticationInfo.AppVersion = authInfo.Version;
                         }
                     }
 
-                    if ((DateTime.UtcNow - tokenInfo.DateLastActivity).TotalMinutes > 3)
+                    if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3)
                     {
-                        tokenInfo.DateLastActivity = DateTime.UtcNow;
+                        originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow;
                         updateToken = true;
                     }
 
-                    if (!tokenInfo.UserId.Equals(Guid.Empty))
+                    if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty))
                     {
-                        info.User = _userManager.GetUserById(tokenInfo.UserId);
+                        authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId);
 
-                        if (info.User != null && !string.Equals(info.User.Name, tokenInfo.UserName, StringComparison.OrdinalIgnoreCase))
+                        if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase))
                         {
-                            tokenInfo.UserName = info.User.Name;
+                            originalAuthenticationInfo.UserName = authInfo.User.Username;
                             updateToken = true;
                         }
                     }
 
                     if (updateToken)
                     {
-                        _authRepo.Update(tokenInfo);
+                        _authRepo.Update(originalAuthenticationInfo);
                     }
                 }
-                httpReq.Items["OriginalAuthenticationInfo"] = tokenInfo;
             }
 
-            httpReq.Items["AuthorizationInfo"] = info;
-
-            return info;
+            return (authInfo, originalAuthenticationInfo);
         }
 
         /// <summary>
@@ -186,6 +215,23 @@ namespace Emby.Server.Implementations.HttpServer.Security
             return GetAuthorization(auth);
         }
 
+        /// <summary>
+        /// Gets the auth.
+        /// </summary>
+        /// <param name="httpReq">The HTTP req.</param>
+        /// <returns>Dictionary{System.StringSystem.String}.</returns>
+        private Dictionary<string, string> GetAuthorizationDictionary(HttpRequest httpReq)
+        {
+            var auth = httpReq.Headers["X-Emby-Authorization"];
+
+            if (string.IsNullOrEmpty(auth))
+            {
+                auth = httpReq.Headers[HeaderNames.Authorization];
+            }
+
+            return GetAuthorization(auth);
+        }
+
         /// <summary>
         /// Gets the authorization.
         /// </summary>
@@ -236,12 +282,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
         private static string NormalizeValue(string value)
         {
-            if (string.IsNullOrEmpty(value))
-            {
-                return value;
-            }
-
-            return WebUtility.HtmlEncode(value);
+            return string.IsNullOrEmpty(value) ? value : WebUtility.HtmlEncode(value);
         }
     }
 }

+ 1 - 1
Emby.Server.Implementations/HttpServer/Security/SessionContext.cs

@@ -1,7 +1,7 @@
 #pragma warning disable CS1591
 
 using System;
-using MediaBrowser.Controller.Entities;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;

+ 6 - 4
Emby.Server.Implementations/HttpServer/WebSocketConnection.cs

@@ -234,10 +234,12 @@ namespace Emby.Server.Implementations.HttpServer
         private Task SendKeepAliveResponse()
         {
             LastKeepAliveDate = DateTime.UtcNow;
-            return SendAsync(new WebSocketMessage<string>
-            {
-                MessageType = "KeepAlive"
-            }, CancellationToken.None);
+            return SendAsync(
+                new WebSocketMessage<string>
+                {
+                    MessageId = Guid.NewGuid(),
+                    MessageType = "KeepAlive"
+                }, CancellationToken.None);
         }
 
         /// <inheritdoc />

+ 7 - 2
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -17,6 +17,8 @@ using Emby.Server.Implementations.Library.Resolvers;
 using Emby.Server.Implementations.Library.Validators;
 using Emby.Server.Implementations.Playlists;
 using Emby.Server.Implementations.ScheduledTasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller;
@@ -25,7 +27,6 @@ using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.IO;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
@@ -46,6 +47,9 @@ using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Tasks;
 using MediaBrowser.Providers.MediaInfo;
 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;
 
@@ -1539,7 +1543,8 @@ namespace Emby.Server.Implementations.Library
                 }
 
                 // Handle grouping
-                if (user != null && !string.IsNullOrEmpty(view.ViewType) && UserView.IsEligibleForGrouping(view.ViewType) && user.Configuration.GroupedFolders.Length > 0)
+                if (user != null && !string.IsNullOrEmpty(view.ViewType) && UserView.IsEligibleForGrouping(view.ViewType)
+                    && user.GetPreference(PreferenceKind.GroupedFolders).Length > 0)
                 {
                     return GetUserRootFolder()
                         .GetChildren(user, true)

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

@@ -7,6 +7,8 @@ using System.IO;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Entities;
@@ -14,7 +16,6 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
@@ -190,10 +191,7 @@ namespace Emby.Server.Implementations.Library
                 {
                     if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
                     {
-                        if (!user.Policy.EnableAudioPlaybackTranscoding)
-                        {
-                            source.SupportsTranscoding = false;
-                        }
+                        source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding);
                     }
                 }
             }
@@ -352,7 +350,9 @@ namespace Emby.Server.Implementations.Library
 
         private void SetDefaultSubtitleStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
         {
-            if (userData.SubtitleStreamIndex.HasValue && user.Configuration.RememberSubtitleSelections && user.Configuration.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection)
+            if (userData.SubtitleStreamIndex.HasValue
+                && user.RememberSubtitleSelections
+                && user.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection)
             {
                 var index = userData.SubtitleStreamIndex.Value;
                 // Make sure the saved index is still valid
@@ -363,26 +363,27 @@ namespace Emby.Server.Implementations.Library
                 }
             }
 
-            var preferredSubs = string.IsNullOrEmpty(user.Configuration.SubtitleLanguagePreference)
-                ? Array.Empty<string>() : NormalizeLanguage(user.Configuration.SubtitleLanguagePreference);
+
+            var preferredSubs = string.IsNullOrEmpty(user.SubtitleLanguagePreference)
+                ? Array.Empty<string>() : NormalizeLanguage(user.SubtitleLanguagePreference);
 
             var defaultAudioIndex = source.DefaultAudioStreamIndex;
             var audioLangage = defaultAudioIndex == null
                 ? null
                 : source.MediaStreams.Where(i => i.Type == MediaStreamType.Audio && i.Index == defaultAudioIndex).Select(i => i.Language).FirstOrDefault();
 
-            source.DefaultSubtitleStreamIndex = MediaStreamSelector.GetDefaultSubtitleStreamIndex(source.MediaStreams,
+            source.DefaultSubtitleStreamIndex = MediaStreamSelector.GetDefaultSubtitleStreamIndex(
+                source.MediaStreams,
                 preferredSubs,
-                user.Configuration.SubtitleMode,
+                user.SubtitleMode,
                 audioLangage);
 
-            MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs,
-                user.Configuration.SubtitleMode, audioLangage);
+            MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLangage);
         }
 
         private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
         {
-            if (userData.AudioStreamIndex.HasValue && user.Configuration.RememberAudioSelections && allowRememberingSelection)
+            if (userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
             {
                 var index = userData.AudioStreamIndex.Value;
                 // Make sure the saved index is still valid
@@ -393,11 +394,11 @@ namespace Emby.Server.Implementations.Library
                 }
             }
 
-            var preferredAudio = string.IsNullOrEmpty(user.Configuration.AudioLanguagePreference)
+            var preferredAudio = string.IsNullOrEmpty(user.AudioLanguagePreference)
                 ? Array.Empty<string>()
-                : NormalizeLanguage(user.Configuration.AudioLanguagePreference);
+                : NormalizeLanguage(user.AudioLanguagePreference);
 
-            source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.Configuration.PlayDefaultAudioTrack);
+            source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
         }
 
         public void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user)
@@ -534,7 +535,7 @@ namespace Emby.Server.Implementations.Library
                 mediaSource.RunTimeTicks = null;
             }
 
-            var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio);
+            var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio);
 
             if (audioStream == null || audioStream.Index == -1)
             {
@@ -545,7 +546,7 @@ namespace Emby.Server.Implementations.Library
                 mediaSource.DefaultAudioStreamIndex = audioStream.Index;
             }
 
-            var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video);
+            var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video);
             if (videoStream != null)
             {
                 if (!videoStream.BitRate.HasValue)
@@ -556,17 +557,14 @@ namespace Emby.Server.Implementations.Library
                     {
                         videoStream.BitRate = 30000000;
                     }
-
                     else if (width >= 1900)
                     {
                         videoStream.BitRate = 20000000;
                     }
-
                     else if (width >= 1200)
                     {
                         videoStream.BitRate = 8000000;
                     }
-
                     else if (width >= 700)
                     {
                         videoStream.BitRate = 2000000;
@@ -670,13 +668,14 @@ namespace Emby.Server.Implementations.Library
                     mediaSource.AnalyzeDurationMs = 3000;
                 }
 
-                mediaInfo = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest
+                mediaInfo = await _mediaEncoder.GetMediaInfo(
+                    new MediaInfoRequest
                 {
                     MediaSource = mediaSource,
                     MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video,
                     ExtractChapters = false
-
-                }, cancellationToken).ConfigureAwait(false);
+                },
+                    cancellationToken).ConfigureAwait(false);
 
                 if (cacheFilePath != null)
                 {

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

@@ -3,7 +3,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
-using MediaBrowser.Model.Configuration;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Model.Entities;
 
 namespace Emby.Server.Implementations.Library

+ 7 - 11
Emby.Server.Implementations/Library/MusicManager.cs

@@ -3,6 +3,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
@@ -10,6 +11,7 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
+using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
 
 namespace Emby.Server.Implementations.Library
 {
@@ -75,7 +77,6 @@ namespace Emby.Server.Implementations.Library
                 {
                     return Guid.Empty;
                 }
-
             }).Where(i => !i.Equals(Guid.Empty)).ToArray();
 
             return GetInstantMixFromGenreIds(genreIds, user, dtoOptions);
@@ -105,32 +106,27 @@ namespace Emby.Server.Implementations.Library
                 return GetInstantMixFromGenreIds(new[] { item.Id }, user, dtoOptions);
             }
 
-            var playlist = item as Playlist;
-            if (playlist != null)
+            if (item is Playlist playlist)
             {
                 return GetInstantMixFromPlaylist(playlist, user, dtoOptions);
             }
 
-            var album = item as MusicAlbum;
-            if (album != null)
+            if (item is MusicAlbum album)
             {
                 return GetInstantMixFromAlbum(album, user, dtoOptions);
             }
 
-            var artist = item as MusicArtist;
-            if (artist != null)
+            if (item is MusicArtist artist)
             {
                 return GetInstantMixFromArtist(artist, user, dtoOptions);
             }
 
-            var song = item as Audio;
-            if (song != null)
+            if (item is Audio song)
             {
                 return GetInstantMixFromSong(song, user, dtoOptions);
             }
 
-            var folder = item as Folder;
-            if (folder != null)
+            if (item is Folder folder)
             {
                 return GetInstantMixFromFolder(folder, user, dtoOptions);
             }

+ 3 - 0
Emby.Server.Implementations/Library/SearchEngine.cs

@@ -3,6 +3,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
@@ -12,6 +13,8 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Search;
 using Microsoft.Extensions.Logging;
+using Genre = MediaBrowser.Controller.Entities.Genre;
+using Person = MediaBrowser.Controller.Entities.Person;
 
 namespace Emby.Server.Implementations.Library
 {

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

@@ -5,6 +5,7 @@ using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Threading;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -13,6 +14,7 @@ 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
 {

+ 0 - 1107
Emby.Server.Implementations/Library/UserManager.cs

@@ -1,1107 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Cryptography;
-using MediaBrowser.Common.Events;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Authentication;
-using MediaBrowser.Controller.Devices;
-using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Persistence;
-using MediaBrowser.Controller.Plugins;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Controller.Security;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Cryptography;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Events;
-using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Serialization;
-using MediaBrowser.Model.Users;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Server.Implementations.Library
-{
-    /// <summary>
-    /// Class UserManager.
-    /// </summary>
-    public class UserManager : IUserManager
-    {
-        private readonly object _policySyncLock = new object();
-        private readonly object _configSyncLock = new object();
-
-        private readonly ILogger<UserManager> _logger;
-        private readonly IUserRepository _userRepository;
-        private readonly IXmlSerializer _xmlSerializer;
-        private readonly IJsonSerializer _jsonSerializer;
-        private readonly INetworkManager _networkManager;
-        private readonly IImageProcessor _imageProcessor;
-        private readonly Lazy<IDtoService> _dtoServiceFactory;
-        private readonly IServerApplicationHost _appHost;
-        private readonly IFileSystem _fileSystem;
-        private readonly ICryptoProvider _cryptoProvider;
-
-        private ConcurrentDictionary<Guid, User> _users;
-
-        private IAuthenticationProvider[] _authenticationProviders;
-        private DefaultAuthenticationProvider _defaultAuthenticationProvider;
-
-        private InvalidAuthProvider _invalidAuthProvider;
-
-        private IPasswordResetProvider[] _passwordResetProviders;
-        private DefaultPasswordResetProvider _defaultPasswordResetProvider;
-
-        private IDtoService DtoService => _dtoServiceFactory.Value;
-
-        public UserManager(
-            ILogger<UserManager> logger,
-            IUserRepository userRepository,
-            IXmlSerializer xmlSerializer,
-            INetworkManager networkManager,
-            IImageProcessor imageProcessor,
-            Lazy<IDtoService> dtoServiceFactory,
-            IServerApplicationHost appHost,
-            IJsonSerializer jsonSerializer,
-            IFileSystem fileSystem,
-            ICryptoProvider cryptoProvider)
-        {
-            _logger = logger;
-            _userRepository = userRepository;
-            _xmlSerializer = xmlSerializer;
-            _networkManager = networkManager;
-            _imageProcessor = imageProcessor;
-            _dtoServiceFactory = dtoServiceFactory;
-            _appHost = appHost;
-            _jsonSerializer = jsonSerializer;
-            _fileSystem = fileSystem;
-            _cryptoProvider = cryptoProvider;
-            _users = null;
-        }
-
-        public event EventHandler<GenericEventArgs<User>> UserPasswordChanged;
-
-        /// <summary>
-        /// Occurs when [user updated].
-        /// </summary>
-        public event EventHandler<GenericEventArgs<User>> UserUpdated;
-
-        public event EventHandler<GenericEventArgs<User>> UserPolicyUpdated;
-
-        public event EventHandler<GenericEventArgs<User>> UserConfigurationUpdated;
-
-        public event EventHandler<GenericEventArgs<User>> UserLockedOut;
-
-        public event EventHandler<GenericEventArgs<User>> UserCreated;
-
-        /// <summary>
-        /// Occurs when [user deleted].
-        /// </summary>
-        public event EventHandler<GenericEventArgs<User>> UserDeleted;
-
-        /// <inheritdoc />
-        public IEnumerable<User> Users => _users.Values;
-
-        /// <inheritdoc />
-        public IEnumerable<Guid> UsersIds => _users.Keys;
-
-        /// <summary>
-        /// Called when [user updated].
-        /// </summary>
-        /// <param name="user">The user.</param>
-        private void OnUserUpdated(User user)
-        {
-            UserUpdated?.Invoke(this, new GenericEventArgs<User>(user));
-        }
-
-        /// <summary>
-        /// Called when [user deleted].
-        /// </summary>
-        /// <param name="user">The user.</param>
-        private void OnUserDeleted(User user)
-        {
-            UserDeleted?.Invoke(this, new GenericEventArgs<User>(user));
-        }
-
-        public NameIdPair[] GetAuthenticationProviders()
-        {
-            return _authenticationProviders
-                .Where(i => i.IsEnabled)
-                .OrderBy(i => i is DefaultAuthenticationProvider ? 0 : 1)
-                .ThenBy(i => i.Name)
-                .Select(i => new NameIdPair
-                {
-                    Name = i.Name,
-                    Id = GetAuthenticationProviderId(i)
-                })
-                .ToArray();
-        }
-
-        public NameIdPair[] GetPasswordResetProviders()
-        {
-            return _passwordResetProviders
-                .Where(i => i.IsEnabled)
-                .OrderBy(i => i is DefaultPasswordResetProvider ? 0 : 1)
-                .ThenBy(i => i.Name)
-                .Select(i => new NameIdPair
-                {
-                    Name = i.Name,
-                    Id = GetPasswordResetProviderId(i)
-                })
-                .ToArray();
-        }
-
-        public void AddParts(IEnumerable<IAuthenticationProvider> authenticationProviders, IEnumerable<IPasswordResetProvider> passwordResetProviders)
-        {
-            _authenticationProviders = authenticationProviders.ToArray();
-
-            _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
-
-            _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
-
-            _passwordResetProviders = passwordResetProviders.ToArray();
-
-            _defaultPasswordResetProvider = passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
-        }
-
-        /// <inheritdoc />
-        public User GetUserById(Guid id)
-        {
-            if (id == Guid.Empty)
-            {
-                throw new ArgumentException("Guid can't be empty", nameof(id));
-            }
-
-            _users.TryGetValue(id, out User user);
-            return user;
-        }
-
-        public User GetUserByName(string name)
-        {
-            if (string.IsNullOrWhiteSpace(name))
-            {
-                throw new ArgumentException("Invalid username", nameof(name));
-            }
-
-            return Users.FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase));
-        }
-
-        public void Initialize()
-        {
-            LoadUsers();
-
-            var users = Users;
-
-            // If there are no local users with admin rights, make them all admins
-            if (!users.Any(i => i.Policy.IsAdministrator))
-            {
-                foreach (var user in users)
-                {
-                    user.Policy.IsAdministrator = true;
-                    UpdateUserPolicy(user, user.Policy, false);
-                }
-            }
-        }
-
-        public static bool IsValidUsername(string username)
-        {
-            // 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(username, @"^[\w\-'._@]*$");
-        }
-
-        private static bool IsValidUsernameCharacter(char i)
-            => IsValidUsername(i.ToString(CultureInfo.InvariantCulture));
-
-        public string MakeValidUsername(string username)
-        {
-            if (IsValidUsername(username))
-            {
-                return username;
-            }
-
-            // Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)
-            var builder = new StringBuilder();
-
-            foreach (var c in username)
-            {
-                if (IsValidUsernameCharacter(c))
-                {
-                    builder.Append(c);
-                }
-            }
-
-            return builder.ToString();
-        }
-
-        public async Task<User> AuthenticateUser(
-            string username,
-            string password,
-            string hashedPassword,
-            string remoteEndPoint,
-            bool isUserSession)
-        {
-            if (string.IsNullOrWhiteSpace(username))
-            {
-                _logger.LogInformation("Authentication request without username has been denied (IP: {IP}).", remoteEndPoint);
-                throw new ArgumentNullException(nameof(username));
-            }
-
-            var user = Users.FirstOrDefault(i => string.Equals(username, i.Name, StringComparison.OrdinalIgnoreCase));
-
-            var success = false;
-            IAuthenticationProvider authenticationProvider = null;
-
-            if (user != null)
-            {
-                var authResult = await AuthenticateLocalUser(username, password, hashedPassword, user, remoteEndPoint).ConfigureAwait(false);
-                authenticationProvider = authResult.authenticationProvider;
-                success = authResult.success;
-            }
-            else
-            {
-                // user is null
-                var authResult = await AuthenticateLocalUser(username, password, hashedPassword, null, remoteEndPoint).ConfigureAwait(false);
-                authenticationProvider = authResult.authenticationProvider;
-                string updatedUsername = authResult.username;
-                success = authResult.success;
-
-                if (success
-                    && authenticationProvider != null
-                    && !(authenticationProvider is DefaultAuthenticationProvider))
-                {
-                    // Trust the username returned by the authentication provider
-                    username = updatedUsername;
-
-                    // Search the database for the user again
-                    // the authentication provider might have created it
-                    user = Users
-                        .FirstOrDefault(i => string.Equals(username, i.Name, StringComparison.OrdinalIgnoreCase));
-
-                    if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy)
-                    {
-                        var policy = hasNewUserPolicy.GetNewUserPolicy();
-                        UpdateUserPolicy(user, policy, true);
-                    }
-                }
-            }
-
-            if (success && user != null && authenticationProvider != null)
-            {
-                var providerId = GetAuthenticationProviderId(authenticationProvider);
-
-                if (!string.Equals(providerId, user.Policy.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
-                {
-                    user.Policy.AuthenticationProviderId = providerId;
-                    UpdateUserPolicy(user, user.Policy, true);
-                }
-            }
-
-            if (user == null)
-            {
-                _logger.LogInformation("Authentication request for {UserName} has been denied (IP: {IP}).", username, remoteEndPoint);
-                throw new AuthenticationException("Invalid username or password entered.");
-            }
-
-            if (user.Policy.IsDisabled)
-            {
-                _logger.LogInformation("Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", username, remoteEndPoint);
-                throw new SecurityException($"The {user.Name} account is currently disabled. Please consult with your administrator.");
-            }
-
-            if (!user.Policy.EnableRemoteAccess && !_networkManager.IsInLocalNetwork(remoteEndPoint))
-            {
-                _logger.LogInformation("Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", username, remoteEndPoint);
-                throw new SecurityException("Forbidden.");
-            }
-
-            if (!user.IsParentalScheduleAllowed())
-            {
-                _logger.LogInformation("Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", username, remoteEndPoint);
-                throw new SecurityException("User is not allowed access at this time.");
-            }
-
-            // Update LastActivityDate and LastLoginDate, then save
-            if (success)
-            {
-                if (isUserSession)
-                {
-                    user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
-                    UpdateUser(user);
-                }
-
-                ResetInvalidLoginAttemptCount(user);
-                _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Name);
-            }
-            else
-            {
-                IncrementInvalidLoginAttemptCount(user);
-                _logger.LogInformation("Authentication request for {UserName} has been denied (IP: {IP}).", user.Name, remoteEndPoint);
-            }
-
-            return success ? user : null;
-        }
-
-#nullable enable
-
-        private static string GetAuthenticationProviderId(IAuthenticationProvider provider)
-        {
-            return provider.GetType().FullName;
-        }
-
-        private static string GetPasswordResetProviderId(IPasswordResetProvider provider)
-        {
-            return provider.GetType().FullName;
-        }
-
-        private IAuthenticationProvider GetAuthenticationProvider(User user)
-        {
-            return GetAuthenticationProviders(user)[0];
-        }
-
-        private IPasswordResetProvider GetPasswordResetProvider(User user)
-        {
-            return GetPasswordResetProviders(user)[0];
-        }
-
-        private IAuthenticationProvider[] GetAuthenticationProviders(User? user)
-        {
-            var authenticationProviderId = user?.Policy.AuthenticationProviderId;
-
-            var providers = _authenticationProviders.Where(i => i.IsEnabled).ToArray();
-
-            if (!string.IsNullOrEmpty(authenticationProviderId))
-            {
-                providers = providers.Where(i => string.Equals(authenticationProviderId, GetAuthenticationProviderId(i), StringComparison.OrdinalIgnoreCase)).ToArray();
-            }
-
-            if (providers.Length == 0)
-            {
-                // Assign the user to the InvalidAuthProvider since no configured auth provider was valid/found
-                _logger.LogWarning("User {UserName} was found with invalid/missing Authentication Provider {AuthenticationProviderId}. Assigning user to InvalidAuthProvider until this is corrected", user?.Name, user?.Policy.AuthenticationProviderId);
-                providers = new IAuthenticationProvider[] { _invalidAuthProvider };
-            }
-
-            return providers;
-        }
-
-        private IPasswordResetProvider[] GetPasswordResetProviders(User? user)
-        {
-            var passwordResetProviderId = user?.Policy.PasswordResetProviderId;
-
-            var providers = _passwordResetProviders.Where(i => i.IsEnabled).ToArray();
-
-            if (!string.IsNullOrEmpty(passwordResetProviderId))
-            {
-                providers = providers.Where(i => string.Equals(passwordResetProviderId, GetPasswordResetProviderId(i), StringComparison.OrdinalIgnoreCase)).ToArray();
-            }
-
-            if (providers.Length == 0)
-            {
-                providers = new IPasswordResetProvider[] { _defaultPasswordResetProvider };
-            }
-
-            return providers;
-        }
-
-        private async Task<(string username, bool success)> AuthenticateWithProvider(
-            IAuthenticationProvider provider,
-            string username,
-            string password,
-            User? resolvedUser)
-        {
-            try
-            {
-                var authenticationResult = provider is IRequiresResolvedUser requiresResolvedUser
-                    ? await requiresResolvedUser.Authenticate(username, password, resolvedUser).ConfigureAwait(false)
-                    : await provider.Authenticate(username, password).ConfigureAwait(false);
-
-                if (authenticationResult.Username != username)
-                {
-                    _logger.LogDebug("Authentication provider provided updated username {1}", authenticationResult.Username);
-                    username = authenticationResult.Username;
-                }
-
-                return (username, true);
-            }
-            catch (AuthenticationException ex)
-            {
-                _logger.LogError(ex, "Error authenticating with provider {Provider}", provider.Name);
-
-                return (username, false);
-            }
-        }
-
-        private async Task<(IAuthenticationProvider? authenticationProvider, string username, bool success)> AuthenticateLocalUser(
-            string username,
-            string password,
-            string hashedPassword,
-            User? user,
-            string remoteEndPoint)
-        {
-            bool success = false;
-            IAuthenticationProvider? authenticationProvider = null;
-
-            foreach (var provider in GetAuthenticationProviders(user))
-            {
-                var providerAuthResult = await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false);
-                var updatedUsername = providerAuthResult.username;
-                success = providerAuthResult.success;
-
-                if (success)
-                {
-                    authenticationProvider = provider;
-                    username = updatedUsername;
-                    break;
-                }
-            }
-
-            if (!success
-                && _networkManager.IsInLocalNetwork(remoteEndPoint)
-                && user?.Configuration.EnableLocalPassword == true
-                && !string.IsNullOrEmpty(user.EasyPassword))
-            {
-                // Check easy password
-                var passwordHash = PasswordHash.Parse(user.EasyPassword);
-                var hash = _cryptoProvider.ComputeHash(
-                    passwordHash.Id,
-                    Encoding.UTF8.GetBytes(password),
-                    passwordHash.Salt.ToArray());
-                success = passwordHash.Hash.SequenceEqual(hash);
-            }
-
-            return (authenticationProvider, username, success);
-        }
-
-        private void ResetInvalidLoginAttemptCount(User user)
-        {
-            user.Policy.InvalidLoginAttemptCount = 0;
-            UpdateUserPolicy(user, user.Policy, false);
-        }
-
-        private void IncrementInvalidLoginAttemptCount(User user)
-        {
-            int invalidLogins = ++user.Policy.InvalidLoginAttemptCount;
-            int maxInvalidLogins = user.Policy.LoginAttemptsBeforeLockout;
-            if (maxInvalidLogins > 0
-                && invalidLogins >= maxInvalidLogins)
-            {
-                user.Policy.IsDisabled = true;
-                UserLockedOut?.Invoke(this, new GenericEventArgs<User>(user));
-                _logger.LogWarning(
-                    "Disabling user {UserName} due to {Attempts} unsuccessful login attempts.",
-                    user.Name,
-                    invalidLogins);
-            }
-
-            UpdateUserPolicy(user, user.Policy, false);
-        }
-
-        /// <summary>
-        /// Loads the users from the repository.
-        /// </summary>
-        private void LoadUsers()
-        {
-            var users = _userRepository.RetrieveAllUsers();
-
-            // There always has to be at least one user.
-            if (users.Count != 0)
-            {
-                _users = new ConcurrentDictionary<Guid, User>(
-                    users.Select(x => new KeyValuePair<Guid, User>(x.Id, x)));
-                return;
-            }
-
-            var defaultName = Environment.UserName;
-            if (string.IsNullOrWhiteSpace(defaultName))
-            {
-                defaultName = "MyJellyfinUser";
-            }
-
-            _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
-
-            var name = MakeValidUsername(defaultName);
-
-            var user = InstantiateNewUser(name);
-
-            user.DateLastSaved = DateTime.UtcNow;
-
-            _userRepository.CreateUser(user);
-
-            user.Policy.IsAdministrator = true;
-            user.Policy.EnableContentDeletion = true;
-            user.Policy.EnableRemoteControlOfOtherUsers = true;
-            UpdateUserPolicy(user, user.Policy, false);
-
-            _users = new ConcurrentDictionary<Guid, User>();
-            _users[user.Id] = user;
-        }
-
-#nullable restore
-
-        public UserDto GetUserDto(User user, string remoteEndPoint = null)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            bool hasConfiguredPassword = GetAuthenticationProvider(user).HasPassword(user);
-            bool hasConfiguredEasyPassword = !string.IsNullOrEmpty(GetAuthenticationProvider(user).GetEasyPasswordHash(user));
-
-            bool hasPassword = user.Configuration.EnableLocalPassword && !string.IsNullOrEmpty(remoteEndPoint) && _networkManager.IsInLocalNetwork(remoteEndPoint) ?
-                hasConfiguredEasyPassword :
-                hasConfiguredPassword;
-
-            UserDto dto = new UserDto
-            {
-                Id = user.Id,
-                Name = user.Name,
-                HasPassword = hasPassword,
-                HasConfiguredPassword = hasConfiguredPassword,
-                HasConfiguredEasyPassword = hasConfiguredEasyPassword,
-                LastActivityDate = user.LastActivityDate,
-                LastLoginDate = user.LastLoginDate,
-                Configuration = user.Configuration,
-                ServerId = _appHost.SystemId,
-                Policy = user.Policy
-            };
-
-            if (!hasPassword && _users.Count == 1)
-            {
-                dto.EnableAutoLogin = true;
-            }
-
-            ItemImageInfo image = user.GetImageInfo(ImageType.Primary, 0);
-
-            if (image != null)
-            {
-                dto.PrimaryImageTag = GetImageCacheTag(user, image);
-
-                try
-                {
-                    DtoService.AttachPrimaryImageAspectRatio(dto, user);
-                }
-                catch (Exception ex)
-                {
-                    // Have to use a catch-all unfortunately because some .net image methods throw plain Exceptions
-                    _logger.LogError(ex, "Error generating PrimaryImageAspectRatio for {User}", user.Name);
-                }
-            }
-
-            return dto;
-        }
-
-        public UserDto GetOfflineUserDto(User user)
-        {
-            var dto = GetUserDto(user);
-
-            dto.ServerName = _appHost.FriendlyName;
-
-            return dto;
-        }
-
-        private string GetImageCacheTag(BaseItem item, ItemImageInfo image)
-        {
-            try
-            {
-                return _imageProcessor.GetImageCacheTag(item, image);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error getting {ImageType} image info for {ImagePath}", image.Type, image.Path);
-                return null;
-            }
-        }
-
-        /// <summary>
-        /// Refreshes metadata for each user
-        /// </summary>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <returns>Task.</returns>
-        public async Task RefreshUsersMetadata(CancellationToken cancellationToken)
-        {
-            foreach (var user in Users)
-            {
-                await user.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false);
-            }
-        }
-
-        /// <summary>
-        /// Renames the user.
-        /// </summary>
-        /// <param name="user">The user.</param>
-        /// <param name="newName">The new name.</param>
-        /// <returns>Task.</returns>
-        /// <exception cref="ArgumentNullException">user</exception>
-        /// <exception cref="ArgumentException"></exception>
-        public async Task RenameUser(User user, string newName)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            if (string.IsNullOrWhiteSpace(newName))
-            {
-                throw new ArgumentException("Invalid username", nameof(newName));
-            }
-
-            if (user.Name.Equals(newName, StringComparison.Ordinal))
-            {
-                throw new ArgumentException("The new and old names must be different.");
-            }
-
-            if (Users.Any(
-                u => u.Id != user.Id && u.Name.Equals(newName, StringComparison.OrdinalIgnoreCase)))
-            {
-                throw new ArgumentException(string.Format(
-                    CultureInfo.InvariantCulture,
-                    "A user with the name '{0}' already exists.",
-                    newName));
-            }
-
-            await user.Rename(newName).ConfigureAwait(false);
-
-            OnUserUpdated(user);
-        }
-
-        /// <summary>
-        /// Updates the user.
-        /// </summary>
-        /// <param name="user">The user.</param>
-        /// <exception cref="ArgumentNullException">user</exception>
-        /// <exception cref="ArgumentException"></exception>
-        public void UpdateUser(User user)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            if (user.Id == Guid.Empty)
-            {
-                throw new ArgumentException("Id can't be empty.", nameof(user));
-            }
-
-            if (!_users.ContainsKey(user.Id))
-            {
-                throw new ArgumentException(
-                    string.Format(
-                        CultureInfo.InvariantCulture,
-                        "A user '{0}' with Id {1} does not exist.",
-                        user.Name,
-                        user.Id),
-                    nameof(user));
-            }
-
-            user.DateModified = DateTime.UtcNow;
-            user.DateLastSaved = DateTime.UtcNow;
-
-            _userRepository.UpdateUser(user);
-
-            OnUserUpdated(user);
-        }
-
-        /// <summary>
-        /// Creates the user.
-        /// </summary>
-        /// <param name="name">The name.</param>
-        /// <returns>User.</returns>
-        /// <exception cref="ArgumentNullException">name</exception>
-        /// <exception cref="ArgumentException"></exception>
-        public User CreateUser(string name)
-        {
-            if (string.IsNullOrWhiteSpace(name))
-            {
-                throw new ArgumentNullException(nameof(name));
-            }
-
-            if (!IsValidUsername(name))
-            {
-                throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)");
-            }
-
-            if (Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
-            {
-                throw new ArgumentException(string.Format("A user with the name '{0}' already exists.", name));
-            }
-
-            var user = InstantiateNewUser(name);
-
-            _users[user.Id] = user;
-
-            user.DateLastSaved = DateTime.UtcNow;
-
-            _userRepository.CreateUser(user);
-
-            EventHelper.QueueEventIfNotNull(UserCreated, this, new GenericEventArgs<User>(user), _logger);
-
-            return user;
-        }
-
-        /// <inheritdoc />
-        /// <exception cref="ArgumentNullException">The <c>user</c> is <c>null</c>.</exception>
-        /// <exception cref="ArgumentException">The <c>user</c> doesn't exist, or is the last administrator.</exception>
-        /// <exception cref="InvalidOperationException">The <c>user</c> can't be deleted; there are no other users.</exception>
-        public void DeleteUser(User user)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            if (!_users.ContainsKey(user.Id))
-            {
-                throw new ArgumentException(string.Format(
-                    CultureInfo.InvariantCulture,
-                    "The user cannot be deleted because there is no user with the Name {0} and Id {1}.",
-                    user.Name,
-                    user.Id));
-            }
-
-            if (_users.Count == 1)
-            {
-                throw new InvalidOperationException(string.Format(
-                    CultureInfo.InvariantCulture,
-                    "The user '{0}' cannot be deleted because there must be at least one user in the system.",
-                    user.Name));
-            }
-
-            if (user.Policy.IsAdministrator
-                && Users.Count(i => i.Policy.IsAdministrator) == 1)
-            {
-                throw new ArgumentException(
-                    string.Format(
-                        CultureInfo.InvariantCulture,
-                        "The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
-                        user.Name),
-                    nameof(user));
-            }
-
-            var configPath = GetConfigurationFilePath(user);
-
-            _userRepository.DeleteUser(user);
-
-            // Delete user config dir
-            lock (_configSyncLock)
-                lock (_policySyncLock)
-                {
-                    try
-                    {
-                        Directory.Delete(user.ConfigurationDirectoryPath, true);
-                    }
-                    catch (IOException ex)
-                    {
-                        _logger.LogError(ex, "Error deleting user config dir: {Path}", user.ConfigurationDirectoryPath);
-                    }
-                }
-
-            _users.TryRemove(user.Id, out _);
-
-            OnUserDeleted(user);
-        }
-
-        /// <summary>
-        /// Resets the password by clearing it.
-        /// </summary>
-        /// <returns>Task.</returns>
-        public Task ResetPassword(User user)
-        {
-            return ChangePassword(user, string.Empty);
-        }
-
-        public void ResetEasyPassword(User user)
-        {
-            ChangeEasyPassword(user, string.Empty, null);
-        }
-
-        public async Task ChangePassword(User user, string newPassword)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
-
-            UpdateUser(user);
-
-            UserPasswordChanged?.Invoke(this, new GenericEventArgs<User>(user));
-        }
-
-        public void ChangeEasyPassword(User user, string newPassword, string newPasswordHash)
-        {
-            if (user == null)
-            {
-                throw new ArgumentNullException(nameof(user));
-            }
-
-            GetAuthenticationProvider(user).ChangeEasyPassword(user, newPassword, newPasswordHash);
-
-            UpdateUser(user);
-
-            UserPasswordChanged?.Invoke(this, new GenericEventArgs<User>(user));
-        }
-
-        /// <summary>
-        /// Instantiates the new user.
-        /// </summary>
-        /// <param name="name">The name.</param>
-        /// <returns>User.</returns>
-        private static User InstantiateNewUser(string name)
-        {
-            return new User
-            {
-                Name = name,
-                Id = Guid.NewGuid(),
-                DateCreated = DateTime.UtcNow,
-                DateModified = DateTime.UtcNow
-            };
-        }
-
-        public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
-        {
-            var user = string.IsNullOrWhiteSpace(enteredUsername) ?
-                null :
-                GetUserByName(enteredUsername);
-
-            var action = ForgotPasswordAction.InNetworkRequired;
-
-            if (user != null && isInNetwork)
-            {
-                var passwordResetProvider = GetPasswordResetProvider(user);
-                return await passwordResetProvider.StartForgotPasswordProcess(user, isInNetwork).ConfigureAwait(false);
-            }
-            else
-            {
-                return new ForgotPasswordResult
-                {
-                    Action = action,
-                    PinFile = string.Empty
-                };
-            }
-        }
-
-        public async Task<PinRedeemResult> RedeemPasswordResetPin(string pin)
-        {
-            foreach (var provider in _passwordResetProviders)
-            {
-                var result = await provider.RedeemPasswordResetPin(pin).ConfigureAwait(false);
-                if (result.Success)
-                {
-                    return result;
-                }
-            }
-
-            return new PinRedeemResult
-            {
-                Success = false,
-                UsersReset = Array.Empty<string>()
-            };
-        }
-
-        public UserPolicy GetUserPolicy(User user)
-        {
-            var path = GetPolicyFilePath(user);
-            if (!File.Exists(path))
-            {
-                return GetDefaultPolicy();
-            }
-
-            try
-            {
-                lock (_policySyncLock)
-                {
-                    return (UserPolicy)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), path);
-                }
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error reading policy file: {Path}", path);
-
-                return GetDefaultPolicy();
-            }
-        }
-
-        private static UserPolicy GetDefaultPolicy()
-        {
-            return new UserPolicy
-            {
-                EnableContentDownloading = true,
-                EnableSyncTranscoding = true
-            };
-        }
-
-        public void UpdateUserPolicy(Guid userId, UserPolicy userPolicy)
-        {
-            var user = GetUserById(userId);
-            UpdateUserPolicy(user, userPolicy, true);
-        }
-
-        private void UpdateUserPolicy(User user, UserPolicy userPolicy, bool fireEvent)
-        {
-            // The xml serializer will output differently if the type is not exact
-            if (userPolicy.GetType() != typeof(UserPolicy))
-            {
-                var json = _jsonSerializer.SerializeToString(userPolicy);
-                userPolicy = _jsonSerializer.DeserializeFromString<UserPolicy>(json);
-            }
-
-            var path = GetPolicyFilePath(user);
-
-            Directory.CreateDirectory(Path.GetDirectoryName(path));
-
-            lock (_policySyncLock)
-            {
-                _xmlSerializer.SerializeToFile(userPolicy, path);
-                user.Policy = userPolicy;
-            }
-
-            if (fireEvent)
-            {
-                UserPolicyUpdated?.Invoke(this, new GenericEventArgs<User>(user));
-            }
-        }
-
-        private static string GetPolicyFilePath(User user)
-        {
-            return Path.Combine(user.ConfigurationDirectoryPath, "policy.xml");
-        }
-
-        private static string GetConfigurationFilePath(User user)
-        {
-            return Path.Combine(user.ConfigurationDirectoryPath, "config.xml");
-        }
-
-        public UserConfiguration GetUserConfiguration(User user)
-        {
-            var path = GetConfigurationFilePath(user);
-
-            if (!File.Exists(path))
-            {
-                return new UserConfiguration();
-            }
-
-            try
-            {
-                lock (_configSyncLock)
-                {
-                    return (UserConfiguration)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), path);
-                }
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error reading policy file: {Path}", path);
-
-                return new UserConfiguration();
-            }
-        }
-
-        public void UpdateConfiguration(Guid userId, UserConfiguration config)
-        {
-            var user = GetUserById(userId);
-            UpdateConfiguration(user, config);
-        }
-
-        public void UpdateConfiguration(User user, UserConfiguration config)
-        {
-            UpdateConfiguration(user, config, true);
-        }
-
-        private void UpdateConfiguration(User user, UserConfiguration config, bool fireEvent)
-        {
-            var path = GetConfigurationFilePath(user);
-
-            // The xml serializer will output differently if the type is not exact
-            if (config.GetType() != typeof(UserConfiguration))
-            {
-                var json = _jsonSerializer.SerializeToString(config);
-                config = _jsonSerializer.DeserializeFromString<UserConfiguration>(json);
-            }
-
-            Directory.CreateDirectory(Path.GetDirectoryName(path));
-
-            lock (_configSyncLock)
-            {
-                _xmlSerializer.SerializeToFile(config, path);
-                user.Configuration = config;
-            }
-
-            if (fireEvent)
-            {
-                UserConfigurationUpdated?.Invoke(this, new GenericEventArgs<User>(user));
-            }
-        }
-    }
-
-    public class DeviceAccessEntryPoint : IServerEntryPoint
-    {
-        private IUserManager _userManager;
-        private IAuthenticationRepository _authRepo;
-        private IDeviceManager _deviceManager;
-        private ISessionManager _sessionManager;
-
-        public DeviceAccessEntryPoint(IUserManager userManager, IAuthenticationRepository authRepo, IDeviceManager deviceManager, ISessionManager sessionManager)
-        {
-            _userManager = userManager;
-            _authRepo = authRepo;
-            _deviceManager = deviceManager;
-            _sessionManager = sessionManager;
-        }
-
-        public Task RunAsync()
-        {
-            _userManager.UserPolicyUpdated += _userManager_UserPolicyUpdated;
-
-            return Task.CompletedTask;
-        }
-
-        private void _userManager_UserPolicyUpdated(object sender, GenericEventArgs<User> e)
-        {
-            var user = e.Argument;
-            if (!user.Policy.EnableAllDevices)
-            {
-                UpdateDeviceAccess(user);
-            }
-        }
-
-        private void UpdateDeviceAccess(User user)
-        {
-            var existing = _authRepo.Get(new AuthenticationInfoQuery
-            {
-                UserId = user.Id
-
-            }).Items;
-
-            foreach (var authInfo in existing)
-            {
-                if (!string.IsNullOrEmpty(authInfo.DeviceId) && !_deviceManager.CanAccessDevice(user, authInfo.DeviceId))
-                {
-                    _sessionManager.Logout(authInfo);
-                }
-            }
-        }
-
-        public void Dispose()
-        {
-
-        }
-    }
-}

+ 20 - 10
Emby.Server.Implementations/Library/UserViewManager.cs

@@ -5,6 +5,8 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
 using System.Threading;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dto;
@@ -17,6 +19,8 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Library;
 using MediaBrowser.Model.Querying;
+using Genre = MediaBrowser.Controller.Entities.Genre;
+using Person = MediaBrowser.Controller.Entities.Person;
 
 namespace Emby.Server.Implementations.Library
 {
@@ -125,12 +129,12 @@ namespace Emby.Server.Implementations.Library
 
             if (!query.IncludeHidden)
             {
-                list = list.Where(i => !user.Configuration.MyMediaExcludes.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))).ToList();
+                list = list.Where(i => !user.GetPreference(PreferenceKind.MyMediaExcludes).Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))).ToList();
             }
 
             var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
 
-            var orders = user.Configuration.OrderedViews.ToList();
+            var orders = user.GetPreference(PreferenceKind.OrderedViews).ToList();
 
             return list
                 .OrderBy(i =>
@@ -165,7 +169,13 @@ namespace Emby.Server.Implementations.Library
             return GetUserSubViewWithName(name, parentId, type, sortName);
         }
 
-        private Folder GetUserView(List<ICollectionFolder> parents, string viewType, string localizationKey, string sortName, User user, string[] presetViews)
+        private Folder GetUserView(
+            List<ICollectionFolder> parents,
+            string viewType,
+            string localizationKey,
+            string sortName,
+            Jellyfin.Data.Entities.User user,
+            string[] presetViews)
         {
             if (parents.Count == 1 && parents.All(i => string.Equals(i.CollectionType, viewType, StringComparison.OrdinalIgnoreCase)))
             {
@@ -270,7 +280,8 @@ namespace Emby.Server.Implementations.Library
             {
                 parents = _libraryManager.GetUserRootFolder().GetChildren(user, true)
                     .Where(i => i is Folder)
-                    .Where(i => !user.Configuration.LatestItemsExcludes.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
+                    .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes)
+                        .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
                     .ToList();
             }
 
@@ -331,12 +342,11 @@ namespace Emby.Server.Implementations.Library
 
             var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Count == 0 ? new[]
             {
-                typeof(Person).Name,
-                typeof(Studio).Name,
-                typeof(Year).Name,
-                typeof(MusicGenre).Name,
-                typeof(Genre).Name
-
+                nameof(Person),
+                nameof(Studio),
+                nameof(Year),
+                nameof(MusicGenre),
+                nameof(Genre)
             } : Array.Empty<string>();
 
             var query = new InternalItemsQuery(user)

+ 12 - 13
Emby.Server.Implementations/LiveTv/LiveTvManager.cs

@@ -7,6 +7,8 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using Emby.Server.Implementations.Library;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Progress;
@@ -14,8 +16,6 @@ using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Movies;
-using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.Persistence;
@@ -31,6 +31,8 @@ using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Tasks;
 using Microsoft.Extensions.Logging;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
 
 namespace Emby.Server.Implementations.LiveTv
 {
@@ -696,7 +698,6 @@ namespace Emby.Server.Implementations.LiveTv
                     {
                         Path = info.ThumbImageUrl,
                         Type = ImageType.Thumb
-
                     }, 0);
                 }
             }
@@ -709,7 +710,6 @@ namespace Emby.Server.Implementations.LiveTv
                     {
                         Path = info.LogoImageUrl,
                         Type = ImageType.Logo
-
                     }, 0);
                 }
             }
@@ -722,7 +722,6 @@ namespace Emby.Server.Implementations.LiveTv
                     {
                         Path = info.BackdropImageUrl,
                         Type = ImageType.Backdrop
-
                     }, 0);
                 }
             }
@@ -760,7 +759,8 @@ namespace Emby.Server.Implementations.LiveTv
 
             var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user);
 
-            var list = new List<Tuple<BaseItemDto, string, string>>() {
+            var list = new List<Tuple<BaseItemDto, string, string>>
+            {
                 new Tuple<BaseItemDto, string, string>(dto, program.ExternalId, program.ExternalSeriesId)
             };
 
@@ -2167,20 +2167,19 @@ namespace Emby.Server.Implementations.LiveTv
             var info = new LiveTvInfo
             {
                 Services = services,
-                IsEnabled = services.Length > 0
+                IsEnabled = services.Length > 0,
+                EnabledUsers = _userManager.Users
+                    .Where(IsLiveTvEnabled)
+                    .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
+                    .ToArray()
             };
 
-            info.EnabledUsers = _userManager.Users
-                .Where(IsLiveTvEnabled)
-                .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
-                .ToArray();
-
             return info;
         }
 
         private bool IsLiveTvEnabled(User user)
         {
-            return user.Policy.EnableLiveTvAccess && (Services.Count > 1 || GetConfiguration().TunerHosts.Length > 0);
+            return user.HasPermission(PermissionKind.EnableLiveTvAccess) && (Services.Count > 1 || GetConfiguration().TunerHosts.Length > 0);
         }
 
         public IEnumerable<User> GetEnabledUsers()

+ 2 - 1
Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs

@@ -3,6 +3,7 @@
 using System.Collections.Generic;
 using System.Linq;
 using System.Text.Json.Serialization;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Model.Querying;
@@ -44,7 +45,7 @@ namespace Emby.Server.Implementations.Playlists
             }
 
             query.Recursive = true;
-            query.IncludeItemTypes = new string[] { "Playlist" };
+            query.IncludeItemTypes = new[] { "Playlist" };
             query.Parent = null;
             return LibraryManager.GetItemsResult(query);
         }

+ 3 - 0
Emby.Server.Implementations/Playlists/PlaylistManager.cs

@@ -7,6 +7,7 @@ using System.IO;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
@@ -21,6 +22,8 @@ using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 using PlaylistsNET.Content;
 using PlaylistsNET.Models;
+using Genre = MediaBrowser.Controller.Entities.Genre;
+using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
 
 namespace Emby.Server.Implementations.Playlists
 {

+ 52 - 30
Emby.Server.Implementations/Session/SessionManager.cs

@@ -7,6 +7,8 @@ using System.Globalization;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Common.Events;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller;
@@ -15,7 +17,6 @@ using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;
@@ -28,7 +29,9 @@ using MediaBrowser.Model.Library;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Session;
 using MediaBrowser.Model.SyncPlay;
+using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Logging;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 
 namespace Emby.Server.Implementations.Session
 {
@@ -283,11 +286,18 @@ namespace Emby.Server.Implementations.Session
             if (user != null)
             {
                 var userLastActivityDate = user.LastActivityDate ?? DateTime.MinValue;
-                user.LastActivityDate = activityDate;
 
                 if ((activityDate - userLastActivityDate).TotalSeconds > 60)
                 {
-                    _userManager.UpdateUser(user);
+                    try
+                    {
+                        user.LastActivityDate = activityDate;
+                        _userManager.UpdateUser(user);
+                    }
+                    catch (DbUpdateConcurrencyException e)
+                    {
+                        _logger.LogWarning(e, "Error updating user's last activity date.");
+                    }
                 }
             }
 
@@ -434,7 +444,13 @@ namespace Emby.Server.Implementations.Session
         /// <param name="remoteEndPoint">The remote end point.</param>
         /// <param name="user">The user.</param>
         /// <returns>SessionInfo.</returns>
-        private SessionInfo GetSessionInfo(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, User user)
+        private SessionInfo GetSessionInfo(
+            string appName,
+            string appVersion,
+            string deviceId,
+            string deviceName,
+            string remoteEndPoint,
+            User user)
         {
             CheckDisposed();
 
@@ -447,14 +463,13 @@ namespace Emby.Server.Implementations.Session
 
             CheckDisposed();
 
-            var sessionInfo = _activeConnections.GetOrAdd(key, k =>
-            {
-                return CreateSession(k, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
-            });
+            var sessionInfo = _activeConnections.GetOrAdd(
+                key,
+                k => CreateSession(k, appName, appVersion, deviceId, deviceName, remoteEndPoint, user));
 
-            sessionInfo.UserId = user == null ? Guid.Empty : user.Id;
-            sessionInfo.UserName = user?.Name;
-            sessionInfo.UserPrimaryImageTag = user == null ? null : GetImageCacheTag(user, ImageType.Primary);
+            sessionInfo.UserId = user?.Id ?? Guid.Empty;
+            sessionInfo.UserName = user?.Username;
+            sessionInfo.UserPrimaryImageTag = user?.ProfileImage == null ? null : GetImageCacheTag(user);
             sessionInfo.RemoteEndPoint = remoteEndPoint;
             sessionInfo.Client = appName;
 
@@ -473,7 +488,14 @@ namespace Emby.Server.Implementations.Session
             return sessionInfo;
         }
 
-        private SessionInfo CreateSession(string key, string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, User user)
+        private SessionInfo CreateSession(
+            string key,
+            string appName,
+            string appVersion,
+            string deviceId,
+            string deviceName,
+            string remoteEndPoint,
+            User user)
         {
             var sessionInfo = new SessionInfo(this, _logger)
             {
@@ -483,11 +505,11 @@ namespace Emby.Server.Implementations.Session
                 Id = key.GetMD5().ToString("N", CultureInfo.InvariantCulture)
             };
 
-            var username = user?.Name;
+            var username = user?.Username;
 
             sessionInfo.UserId = user?.Id ?? Guid.Empty;
             sessionInfo.UserName = username;
-            sessionInfo.UserPrimaryImageTag = user == null ? null : GetImageCacheTag(user, ImageType.Primary);
+            sessionInfo.UserPrimaryImageTag = user?.ProfileImage == null ? null : GetImageCacheTag(user);
             sessionInfo.RemoteEndPoint = remoteEndPoint;
 
             if (string.IsNullOrEmpty(deviceName))
@@ -535,10 +557,7 @@ namespace Emby.Server.Implementations.Session
 
         private void StartIdleCheckTimer()
         {
-            if (_idleTimer == null)
-            {
-                _idleTimer = new Timer(CheckForIdlePlayback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
-            }
+            _idleTimer ??= new Timer(CheckForIdlePlayback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
         }
 
         private void StopIdleCheckTimer()
@@ -786,7 +805,7 @@ namespace Emby.Server.Implementations.Session
         {
             var changed = false;
 
-            if (user.Configuration.RememberAudioSelections)
+            if (user.RememberAudioSelections)
             {
                 if (data.AudioStreamIndex != info.AudioStreamIndex)
                 {
@@ -803,7 +822,7 @@ namespace Emby.Server.Implementations.Session
                 }
             }
 
-            if (user.Configuration.RememberSubtitleSelections)
+            if (user.RememberSubtitleSelections)
             {
                 if (data.SubtitleStreamIndex != info.SubtitleStreamIndex)
                 {
@@ -1114,13 +1133,13 @@ namespace Emby.Server.Implementations.Session
                 if (items.Any(i => i.GetPlayAccess(user) != PlayAccess.Full))
                 {
                     throw new ArgumentException(
-                        string.Format(CultureInfo.InvariantCulture, "{0} is not allowed to play media.", user.Name));
+                        string.Format(CultureInfo.InvariantCulture, "{0} is not allowed to play media.", user.Username));
                 }
             }
 
             if (user != null
                 && command.ItemIds.Length == 1
-                && user.Configuration.EnableNextEpisodeAutoPlay
+                && user.EnableNextEpisodeAutoPlay
                 && _libraryManager.GetItemById(command.ItemIds[0]) is Episode episode)
             {
                 var series = episode.Series;
@@ -1191,7 +1210,7 @@ namespace Emby.Server.Implementations.Session
                     DtoOptions = new DtoOptions(false)
                     {
                         EnableImages = false,
-                        Fields = new ItemFields[]
+                        Fields = new[]
                         {
                             ItemFields.SortName
                         }
@@ -1353,7 +1372,7 @@ namespace Emby.Server.Implementations.Session
                 list.Add(new SessionUserInfo
                 {
                     UserId = userId,
-                    UserName = user.Name
+                    UserName = user.Username
                 });
 
                 session.AdditionalUsers = list.ToArray();
@@ -1513,7 +1532,7 @@ namespace Emby.Server.Implementations.Session
                 DeviceName = deviceName,
                 UserId = user.Id,
                 AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
-                UserName = user.Name
+                UserName = user.Username
             };
 
             _logger.LogInformation("Creating new access token for user {0}", user.Id);
@@ -1710,15 +1729,15 @@ namespace Emby.Server.Implementations.Session
             return info;
         }
 
-        private string GetImageCacheTag(BaseItem item, ImageType type)
+        private string GetImageCacheTag(User user)
         {
             try
             {
-                return _imageProcessor.GetImageCacheTag(item, type);
+                return _imageProcessor.GetImageCacheTag(user);
             }
-            catch (Exception ex)
+            catch (Exception e)
             {
-                _logger.LogError(ex, "Error getting image information for {Type}", type);
+                _logger.LogError(e, "Error getting image information for profile image");
                 return null;
             }
         }
@@ -1827,7 +1846,10 @@ namespace Emby.Server.Implementations.Session
         {
             CheckDisposed();
 
-            var adminUserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id).ToList();
+            var adminUserIds = _userManager.Users
+                .Where(i => i.HasPermission(PermissionKind.IsAdministrator))
+                .Select(i => i.Id)
+                .ToList();
 
             return SendMessageToUserSessions(adminUserIds, name, data, cancellationToken);
         }

+ 1 - 0
Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 
 using System;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Sorting;

+ 1 - 0
Emby.Server.Implementations/Sorting/DatePlayedComparer.cs

@@ -1,4 +1,5 @@
 using System;
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Sorting;

+ 1 - 0
Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs

@@ -1,5 +1,6 @@
 #pragma warning disable CS1591
 
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Sorting;

+ 1 - 0
Emby.Server.Implementations/Sorting/IsPlayedComparer.cs

@@ -1,5 +1,6 @@
 #pragma warning disable CS1591
 
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Sorting;

+ 1 - 0
Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs

@@ -1,5 +1,6 @@
 #pragma warning disable CS1591
 
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Sorting;

+ 1 - 0
Emby.Server.Implementations/Sorting/PlayCountComparer.cs

@@ -1,3 +1,4 @@
+using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Sorting;

+ 27 - 52
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs

@@ -3,13 +3,13 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
 using System.Threading;
-using Microsoft.Extensions.Logging;
-using MediaBrowser.Controller.Entities;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Controller.SyncPlay;
-using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.SyncPlay
 {
@@ -109,14 +109,6 @@ namespace Emby.Server.Implementations.SyncPlay
             _disposed = true;
         }
 
-        private void CheckDisposed()
-        {
-            if (_disposed)
-            {
-                throw new ObjectDisposedException(GetType().Name);
-            }
-        }
-
         private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
         {
             var session = e.SessionInfo;
@@ -149,38 +141,24 @@ namespace Emby.Server.Implementations.SyncPlay
             var item = _libraryManager.GetItemById(itemId);
 
             // Check ParentalRating access
-            var hasParentalRatingAccess = true;
-            if (user.Policy.MaxParentalRating.HasValue)
-            {
-                hasParentalRatingAccess = item.InheritedParentalRatingValue <= user.Policy.MaxParentalRating;
-            }
+            var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue
+                || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating;
 
-            if (!user.Policy.EnableAllFolders && hasParentalRatingAccess)
+            if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess)
             {
                 var collections = _libraryManager.GetCollectionFolders(item).Select(
-                    folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)
-                );
-                var intersect = collections.Intersect(user.Policy.EnabledFolders);
-                return intersect.Any();
-            }
-            else
-            {
-                return hasParentalRatingAccess;
+                    folder => folder.Id.ToString("N", CultureInfo.InvariantCulture));
+
+                return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any();
             }
+
+            return hasParentalRatingAccess;
         }
 
         private Guid? GetSessionGroup(SessionInfo session)
         {
-            ISyncPlayController group;
-            _sessionToGroupMap.TryGetValue(session.Id, out group);
-            if (group != null)
-            {
-                return group.GetGroupId();
-            }
-            else
-            {
-                return null;
-            }
+            _sessionToGroupMap.TryGetValue(session.Id, out var group);
+            return group?.GetGroupId();
         }
 
         /// <inheritdoc />
@@ -188,7 +166,7 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             var user = _userManager.GetUserById(session.UserId);
 
-            if (user.Policy.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
+            if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
             {
                 _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id);
 
@@ -196,7 +174,7 @@ namespace Emby.Server.Implementations.SyncPlay
                 {
                     Type = GroupUpdateType.CreateGroupDenied
                 };
-                _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
                 return;
             }
 
@@ -219,7 +197,7 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             var user = _userManager.GetUserById(session.UserId);
 
-            if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
+            if (user.SyncPlayAccess == SyncPlayAccess.None)
             {
                 _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id);
 
@@ -227,7 +205,7 @@ namespace Emby.Server.Implementations.SyncPlay
                 {
                     Type = GroupUpdateType.JoinGroupDenied
                 };
-                _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
                 return;
             }
 
@@ -244,7 +222,7 @@ namespace Emby.Server.Implementations.SyncPlay
                     {
                         Type = GroupUpdateType.GroupDoesNotExist
                     };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
                     return;
                 }
 
@@ -257,7 +235,7 @@ namespace Emby.Server.Implementations.SyncPlay
                         GroupId = group.GetGroupId().ToString(),
                         Type = GroupUpdateType.LibraryAccessDenied
                     };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
                     return;
                 }
 
@@ -281,8 +259,7 @@ namespace Emby.Server.Implementations.SyncPlay
             // TODO: determine what happens to users that are in a group and get their permissions revoked
             lock (_groupsLock)
             {
-                ISyncPlayController group;
-                _sessionToGroupMap.TryGetValue(session.Id, out group);
+                _sessionToGroupMap.TryGetValue(session.Id, out var group);
 
                 if (group == null)
                 {
@@ -292,7 +269,7 @@ namespace Emby.Server.Implementations.SyncPlay
                     {
                         Type = GroupUpdateType.NotInGroup
                     };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
                     return;
                 }
 
@@ -311,7 +288,7 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             var user = _userManager.GetUserById(session.UserId);
 
-            if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
+            if (user.SyncPlayAccess == SyncPlayAccess.None)
             {
                 return new List<GroupInfoView>();
             }
@@ -341,7 +318,7 @@ namespace Emby.Server.Implementations.SyncPlay
         {
             var user = _userManager.GetUserById(session.UserId);
 
-            if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
+            if (user.SyncPlayAccess == SyncPlayAccess.None)
             {
                 _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id);
 
@@ -349,14 +326,13 @@ namespace Emby.Server.Implementations.SyncPlay
                 {
                     Type = GroupUpdateType.JoinGroupDenied
                 };
-                _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
                 return;
             }
 
             lock (_groupsLock)
             {
-                ISyncPlayController group;
-                _sessionToGroupMap.TryGetValue(session.Id, out group);
+                _sessionToGroupMap.TryGetValue(session.Id, out var group);
 
                 if (group == null)
                 {
@@ -366,7 +342,7 @@ namespace Emby.Server.Implementations.SyncPlay
                     {
                         Type = GroupUpdateType.NotInGroup
                     };
-                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
                     return;
                 }
 
@@ -393,8 +369,7 @@ namespace Emby.Server.Implementations.SyncPlay
                 throw new InvalidOperationException("Session not in any group!");
             }
 
-            ISyncPlayController tempGroup;
-            _sessionToGroupMap.Remove(session.Id, out tempGroup);
+            _sessionToGroupMap.Remove(session.Id, out var tempGroup);
 
             if (!tempGroup.GetGroupId().Equals(group.GetGroupId()))
             {

+ 9 - 5
Emby.Server.Implementations/TV/TVSeriesManager.cs

@@ -4,13 +4,17 @@ using System;
 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.Dto;
 using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.TV;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+using Series = MediaBrowser.Controller.Entities.TV.Series;
 
 namespace Emby.Server.Implementations.TV
 {
@@ -73,7 +77,8 @@ namespace Emby.Server.Implementations.TV
             {
                 parents = _libraryManager.GetUserRootFolder().GetChildren(user, true)
                    .Where(i => i is Folder)
-                   .Where(i => !user.Configuration.LatestItemsExcludes.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
+                   .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes)
+                       .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
                    .ToArray();
             }
 
@@ -191,7 +196,7 @@ namespace Emby.Server.Implementations.TV
             {
                 AncestorWithPresentationUniqueKey = null,
                 SeriesPresentationUniqueKey = seriesKey,
-                IncludeItemTypes = new[] { typeof(Episode).Name },
+                IncludeItemTypes = new[] { nameof(Episode) },
                 OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Descending) },
                 IsPlayed = true,
                 Limit = 1,
@@ -204,7 +209,6 @@ namespace Emby.Server.Implementations.TV
                     },
                     EnableImages = false
                 }
-
             }).FirstOrDefault();
 
             Func<Episode> getEpisode = () =>
@@ -219,7 +223,7 @@ namespace Emby.Server.Implementations.TV
                     IsPlayed = false,
                     IsVirtualItem = false,
                     ParentIndexNumberNotEquals = 0,
-                    MinSortName = lastWatchedEpisode == null ? null : lastWatchedEpisode.SortName,
+                    MinSortName = lastWatchedEpisode?.SortName,
                     DtoOptions = dtoOptions
 
                 }).Cast<Episode>().FirstOrDefault();

+ 102 - 0
Jellyfin.Api/Auth/BaseAuthorizationHandler.cs

@@ -0,0 +1,102 @@
+#nullable enable
+
+using System.Net;
+using System.Security.Claims;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth
+{
+    /// <summary>
+    /// Base authorization handler.
+    /// </summary>
+    /// <typeparam name="T">Type of Authorization Requirement.</typeparam>
+    public abstract class BaseAuthorizationHandler<T> : AuthorizationHandler<T>
+        where T : IAuthorizationRequirement
+    {
+        private readonly IUserManager _userManager;
+        private readonly INetworkManager _networkManager;
+        private readonly IHttpContextAccessor _httpContextAccessor;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="BaseAuthorizationHandler{T}"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        protected BaseAuthorizationHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+        {
+            _userManager = userManager;
+            _networkManager = networkManager;
+            _httpContextAccessor = httpContextAccessor;
+        }
+
+        /// <summary>
+        /// Validate authenticated claims.
+        /// </summary>
+        /// <param name="claimsPrincipal">Request claims.</param>
+        /// <param name="ignoreSchedule">Whether to ignore parental control.</param>
+        /// <param name="localAccessOnly">Whether access is to be allowed locally only.</param>
+        /// <returns>Validated claim status.</returns>
+        protected bool ValidateClaims(
+            ClaimsPrincipal claimsPrincipal,
+            bool ignoreSchedule = false,
+            bool localAccessOnly = false)
+        {
+            // Ensure claim has userId.
+            var userId = ClaimHelpers.GetUserId(claimsPrincipal);
+            if (userId == null)
+            {
+                return false;
+            }
+
+            // Ensure userId links to a valid user.
+            var user = _userManager.GetUserById(userId.Value);
+            if (user == null)
+            {
+                return false;
+            }
+
+            // Ensure user is not disabled.
+            if (user.HasPermission(PermissionKind.IsDisabled))
+            {
+                return false;
+            }
+
+            var ip = NormalizeIp(_httpContextAccessor.HttpContext.Connection.RemoteIpAddress).ToString();
+            var isInLocalNetwork = _networkManager.IsInLocalNetwork(ip);
+            // User cannot access remotely and user is remote
+            if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork)
+            {
+                return false;
+            }
+
+            if (localAccessOnly && !isInLocalNetwork)
+            {
+                return false;
+            }
+
+            // User attempting to access out of parental control hours.
+            if (!ignoreSchedule
+                && !user.HasPermission(PermissionKind.IsAdministrator)
+                && !user.IsParentalScheduleAllowed())
+            {
+                return false;
+            }
+
+            return true;
+        }
+
+        private static IPAddress NormalizeIp(IPAddress ip)
+        {
+            return ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip;
+        }
+    }
+}

+ 15 - 11
Jellyfin.Api/Auth/CustomAuthenticationHandler.cs

@@ -1,8 +1,12 @@
+#nullable enable
+
+using System.Globalization;
 using System.Security.Authentication;
 using System.Security.Claims;
 using System.Text.Encodings.Web;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Net;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.Extensions.Logging;
@@ -38,15 +42,10 @@ namespace Jellyfin.Api.Auth
         /// <inheritdoc />
         protected override Task<AuthenticateResult> HandleAuthenticateAsync()
         {
-            var authenticatedAttribute = new AuthenticatedAttribute
-            {
-                IgnoreLegacyAuth = true
-            };
-
             try
             {
-                var user = _authService.Authenticate(Request, authenticatedAttribute);
-                if (user == null)
+                var authorizationInfo = _authService.Authenticate(Request);
+                if (authorizationInfo == null)
                 {
                     return Task.FromResult(AuthenticateResult.NoResult());
                     // TODO return when legacy API is removed.
@@ -56,11 +55,16 @@ namespace Jellyfin.Api.Auth
 
                 var claims = new[]
                 {
-                    new Claim(ClaimTypes.Name, user.Name),
-                    new Claim(
-                        ClaimTypes.Role,
-                        value: user.Policy.IsAdministrator ? UserRoles.Administrator : UserRoles.User)
+                    new Claim(ClaimTypes.Name, authorizationInfo.User.Username),
+                    new Claim(ClaimTypes.Role, value: authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User),
+                    new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
+                    new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
+                    new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
+                    new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
+                    new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
+                    new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
                 };
+
                 var identity = new ClaimsIdentity(claims, Scheme.Name);
                 var principal = new ClaimsPrincipal(identity);
                 var ticket = new AuthenticationTicket(principal, Scheme.Name);

+ 42 - 0
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs

@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+    /// <summary>
+    /// Default authorization handler.
+    /// </summary>
+    public class DefaultAuthorizationHandler : BaseAuthorizationHandler<DefaultAuthorizationRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DefaultAuthorizationHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public DefaultAuthorizationHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User);
+            if (!validated)
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            context.Succeed(requirement);
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+    /// <summary>
+    /// The default authorization requirement.
+    /// </summary>
+    public class DefaultAuthorizationRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 18 - 4
Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs

@@ -1,22 +1,33 @@
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
 
 namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
 {
     /// <summary>
     /// Authorization handler for requiring first time setup or elevated privileges.
     /// </summary>
-    public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler<FirstTimeSetupOrElevatedRequirement>
+    public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler<FirstTimeSetupOrElevatedRequirement>
     {
         private readonly IConfigurationManager _configurationManager;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="FirstTimeSetupOrElevatedHandler" /> class.
         /// </summary>
-        /// <param name="configurationManager">The jellyfin configuration manager.</param>
-        public FirstTimeSetupOrElevatedHandler(IConfigurationManager configurationManager)
+        /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public FirstTimeSetupOrElevatedHandler(
+            IConfigurationManager configurationManager,
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
         {
             _configurationManager = configurationManager;
         }
@@ -27,8 +38,11 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
             if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
             {
                 context.Succeed(firstTimeSetupOrElevatedRequirement);
+                return Task.CompletedTask;
             }
-            else if (context.User.IsInRole(UserRoles.Administrator))
+
+            var validated = ValidateClaims(context.User);
+            if (validated && context.User.IsInRole(UserRoles.Administrator))
             {
                 context.Succeed(firstTimeSetupOrElevatedRequirement);
             }

+ 42 - 0
Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs

@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy
+{
+    /// <summary>
+    /// Escape schedule controls handler.
+    /// </summary>
+    public class IgnoreScheduleHandler : BaseAuthorizationHandler<IgnoreScheduleRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IgnoreScheduleHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public IgnoreScheduleHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreScheduleRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User, ignoreSchedule: true);
+            if (!validated)
+            {
+                context.Fail();
+                return Task.CompletedTask;
+            }
+
+            context.Succeed(requirement);
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy
+{
+    /// <summary>
+    /// Escape schedule controls requirement.
+    /// </summary>
+    public class IgnoreScheduleRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 44 - 0
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs

@@ -0,0 +1,44 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+    /// <summary>
+    /// Local access handler.
+    /// </summary>
+    public class LocalAccessHandler : BaseAuthorizationHandler<LocalAccessRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LocalAccessHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public LocalAccessHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User, localAccessOnly: true);
+            if (!validated)
+            {
+                context.Fail();
+            }
+            else
+            {
+                context.Succeed(requirement);
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+    /// <summary>
+    /// The local access authorization requirement.
+    /// </summary>
+    public class LocalAccessRequirement : IAuthorizationRequirement
+    {
+    }
+}

+ 24 - 2
Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs

@@ -1,21 +1,43 @@
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
 
 namespace Jellyfin.Api.Auth.RequiresElevationPolicy
 {
     /// <summary>
     /// Authorization handler for requiring elevated privileges.
     /// </summary>
-    public class RequiresElevationHandler : AuthorizationHandler<RequiresElevationRequirement>
+    public class RequiresElevationHandler : BaseAuthorizationHandler<RequiresElevationRequirement>
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="RequiresElevationHandler"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+        public RequiresElevationHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
         /// <inheritdoc />
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement)
         {
-            if (context.User.IsInRole(UserRoles.Administrator))
+            var validated = ValidateClaims(context.User);
+            if (validated && context.User.IsInRole(UserRoles.Administrator))
             {
                 context.Succeed(requirement);
             }
+            else
+            {
+                context.Fail();
+            }
 
             return Task.CompletedTask;
         }

+ 38 - 0
Jellyfin.Api/Constants/InternalClaimTypes.cs

@@ -0,0 +1,38 @@
+namespace Jellyfin.Api.Constants
+{
+    /// <summary>
+    /// Internal claim types for authorization.
+    /// </summary>
+    public static class InternalClaimTypes
+    {
+        /// <summary>
+        /// User Id.
+        /// </summary>
+        public const string UserId = "Jellyfin-UserId";
+
+        /// <summary>
+        /// Device Id.
+        /// </summary>
+        public const string DeviceId = "Jellyfin-DeviceId";
+
+        /// <summary>
+        /// Device.
+        /// </summary>
+        public const string Device = "Jellyfin-Device";
+
+        /// <summary>
+        /// Client.
+        /// </summary>
+        public const string Client = "Jellyfin-Client";
+
+        /// <summary>
+        /// Version.
+        /// </summary>
+        public const string Version = "Jellyfin-Version";
+
+        /// <summary>
+        /// Token.
+        /// </summary>
+        public const string Token = "Jellyfin-Token";
+    }
+}

+ 15 - 0
Jellyfin.Api/Constants/Policies.cs

@@ -5,6 +5,11 @@ namespace Jellyfin.Api.Constants
     /// </summary>
     public static class Policies
     {
+        /// <summary>
+        /// Policy name for default authorization.
+        /// </summary>
+        public const string DefaultAuthorization = "DefaultAuthorization";
+
         /// <summary>
         /// Policy name for requiring first time setup or elevated privileges.
         /// </summary>
@@ -14,5 +19,15 @@ namespace Jellyfin.Api.Constants
         /// Policy name for requiring elevated privileges.
         /// </summary>
         public const string RequiresElevation = "RequiresElevation";
+
+        /// <summary>
+        /// Policy name for allowing local access only.
+        /// </summary>
+        public const string LocalAccessOnly = "LocalAccessOnly";
+
+        /// <summary>
+        /// Policy name for escaping schedule controls.
+        /// </summary>
+        public const string IgnoreSchedule = "IgnoreSchedule";
     }
 }

+ 0 - 1
Jellyfin.Api/Controllers/ActivityLogController.cs

@@ -1,4 +1,3 @@
-#nullable enable
 #pragma warning disable CA1801
 
 using System;

+ 10 - 12
Jellyfin.Api/Controllers/ConfigurationController.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System.Text.Json;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
@@ -18,7 +16,7 @@ namespace Jellyfin.Api.Controllers
     /// Configuration Controller.
     /// </summary>
     [Route("System")]
-    [Authorize]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
     public class ConfigurationController : BaseJellyfinApiController
     {
         private readonly IServerConfigurationManager _configurationManager;
@@ -53,15 +51,15 @@ namespace Jellyfin.Api.Controllers
         /// Updates application configuration.
         /// </summary>
         /// <param name="configuration">Configuration.</param>
-        /// <response code="200">Configuration updated.</response>
+        /// <response code="204">Configuration updated.</response>
         /// <returns>Update status.</returns>
         [HttpPost("Configuration")]
         [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult UpdateConfiguration([FromBody, BindRequired] ServerConfiguration configuration)
         {
             _configurationManager.ReplaceConfiguration(configuration);
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -81,17 +79,17 @@ namespace Jellyfin.Api.Controllers
         /// Updates named configuration.
         /// </summary>
         /// <param name="key">Configuration key.</param>
-        /// <response code="200">Named configuration updated.</response>
+        /// <response code="204">Named configuration updated.</response>
         /// <returns>Update status.</returns>
         [HttpPost("Configuration/{Key}")]
         [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key)
         {
             var configurationType = _configurationManager.GetConfigurationType(key);
             var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false);
             _configurationManager.SaveConfiguration(key, configuration);
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -111,15 +109,15 @@ namespace Jellyfin.Api.Controllers
         /// Updates the path to the media encoder.
         /// </summary>
         /// <param name="mediaEncoderPath">Media encoder path form body.</param>
-        /// <response code="200">Media encoder path updated.</response>
+        /// <response code="204">Media encoder path updated.</response>
         /// <returns>Status.</returns>
         [HttpPost("MediaEncoder/Path")]
         [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult UpdateMediaEncoderPath([FromForm, BindRequired] MediaEncoderPathDto mediaEncoderPath)
         {
             _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
-            return Ok();
+            return NoContent();
         }
     }
 }

+ 9 - 11
Jellyfin.Api/Controllers/DevicesController.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using Jellyfin.Api.Constants;
 using MediaBrowser.Controller.Devices;
@@ -17,7 +15,7 @@ namespace Jellyfin.Api.Controllers
     /// <summary>
     /// Devices Controller.
     /// </summary>
-    [Authorize]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
     public class DevicesController : BaseJellyfinApiController
     {
         private readonly IDeviceManager _deviceManager;
@@ -105,12 +103,12 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="id">Device Id.</param>
         /// <param name="deviceOptions">Device Options.</param>
-        /// <response code="200">Device options updated.</response>
+        /// <response code="204">Device options updated.</response>
         /// <response code="404">Device not found.</response>
-        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
         [HttpPost("Options")]
         [Authorize(Policy = Policies.RequiresElevation)]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult UpdateDeviceOptions(
             [FromQuery, BindRequired] string id,
@@ -123,18 +121,18 @@ namespace Jellyfin.Api.Controllers
             }
 
             _deviceManager.UpdateDeviceOptions(id, deviceOptions);
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
         /// Deletes a device.
         /// </summary>
         /// <param name="id">Device Id.</param>
-        /// <response code="200">Device deleted.</response>
+        /// <response code="204">Device deleted.</response>
         /// <response code="404">Device not found.</response>
-        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
         [HttpDelete]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult DeleteDevice([FromQuery, BindRequired] string id)
         {
             var existingDevice = _deviceManager.GetDevice(id);
@@ -150,7 +148,7 @@ namespace Jellyfin.Api.Controllers
                 _sessionManager.Logout(session);
             }
 
-            return Ok();
+            return NoContent();
         }
     }
 }

+ 220 - 0
Jellyfin.Api/Controllers/FilterController.cs

@@ -0,0 +1,220 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Linq;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Filters controller.
+    /// </summary>
+    [Authorize]
+    public class FilterController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="FilterController"/> class.
+        /// </summary>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        public FilterController(ILibraryManager libraryManager, IUserManager userManager)
+        {
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+        }
+
+        /// <summary>
+        /// Gets legacy query filters.
+        /// </summary>
+        /// <param name="userId">Optional. User id.</param>
+        /// <param name="parentId">Optional. Parent id.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <response code="200">Legacy filters retrieved.</response>
+        /// <returns>Legacy query filters.</returns>
+        [HttpGet("/Items/Filters")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
+            [FromQuery] Guid? userId,
+            [FromQuery] string? parentId,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? mediaTypes)
+        {
+            var parentItem = string.IsNullOrEmpty(parentId)
+                ? null
+                : _libraryManager.GetItemById(parentId);
+
+            var user = userId == null || userId == Guid.Empty
+                ? null
+                : _userManager.GetUserById(userId.Value);
+
+            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            {
+                parentItem = null;
+            }
+
+            var item = string.IsNullOrEmpty(parentId)
+                ? user == null
+                    ? _libraryManager.RootFolder
+                    : _libraryManager.GetUserRootFolder()
+                : parentItem;
+
+            var query = new InternalItemsQuery
+            {
+                User = user,
+                MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                Recursive = true,
+                EnableTotalRecordCount = false,
+                DtoOptions = new DtoOptions
+                {
+                    Fields = new[] { ItemFields.Genres, ItemFields.Tags },
+                    EnableImages = false,
+                    EnableUserData = false
+                }
+            };
+
+            var itemList = ((Folder)item!).GetItemList(query);
+            return new QueryFiltersLegacy
+            {
+                Years = itemList.Select(i => i.ProductionYear ?? -1)
+                    .Where(i => i > 0)
+                    .Distinct()
+                    .OrderBy(i => i)
+                    .ToArray(),
+
+                Genres = itemList.SelectMany(i => i.Genres)
+                    .DistinctNames()
+                    .OrderBy(i => i)
+                    .ToArray(),
+
+                Tags = itemList
+                    .SelectMany(i => i.Tags)
+                    .Distinct(StringComparer.OrdinalIgnoreCase)
+                    .OrderBy(i => i)
+                    .ToArray(),
+
+                OfficialRatings = itemList
+                    .Select(i => i.OfficialRating)
+                    .Where(i => !string.IsNullOrWhiteSpace(i))
+                    .Distinct(StringComparer.OrdinalIgnoreCase)
+                    .OrderBy(i => i)
+                    .ToArray()
+            };
+        }
+
+        /// <summary>
+        /// Gets query filters.
+        /// </summary>
+        /// <param name="userId">Optional. User id.</param>
+        /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="mediaTypes">[Unused] Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <param name="isAiring">Optional. Is item airing.</param>
+        /// <param name="isMovie">Optional. Is item movie.</param>
+        /// <param name="isSports">Optional. Is item sports.</param>
+        /// <param name="isKids">Optional. Is item kids.</param>
+        /// <param name="isNews">Optional. Is item news.</param>
+        /// <param name="isSeries">Optional. Is item series.</param>
+        /// <param name="recursive">Optional. Search recursive.</param>
+        /// <response code="200">Filters retrieved.</response>
+        /// <returns>Query filters.</returns>
+        [HttpGet("/Items/Filters2")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryFilters> GetQueryFilters(
+            [FromQuery] Guid? userId,
+            [FromQuery] string? parentId,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] bool? isAiring,
+            [FromQuery] bool? isMovie,
+            [FromQuery] bool? isSports,
+            [FromQuery] bool? isKids,
+            [FromQuery] bool? isNews,
+            [FromQuery] bool? isSeries,
+            [FromQuery] bool? recursive)
+        {
+            var parentItem = string.IsNullOrEmpty(parentId)
+                ? null
+                : _libraryManager.GetItemById(parentId);
+
+            var user = userId == null || userId == Guid.Empty
+                ? null
+                : _userManager.GetUserById(userId.Value);
+
+            if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+            {
+                parentItem = null;
+            }
+
+            var filters = new QueryFilters();
+            var genreQuery = new InternalItemsQuery(user)
+            {
+                IncludeItemTypes =
+                    (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+                DtoOptions = new DtoOptions
+                {
+                    Fields = Array.Empty<ItemFields>(),
+                    EnableImages = false,
+                    EnableUserData = false
+                },
+                IsAiring = isAiring,
+                IsMovie = isMovie,
+                IsSports = isSports,
+                IsKids = isKids,
+                IsNews = isNews,
+                IsSeries = isSeries
+            };
+
+            if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
+            {
+                genreQuery.AncestorIds = parentItem == null ? Array.Empty<Guid>() : new[] { parentItem.Id };
+            }
+            else
+            {
+                genreQuery.Parent = parentItem;
+            }
+
+            if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
+                || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase))
+            {
+                filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
+                {
+                    Name = i.Item1.Name,
+                    Id = i.Item1.Id
+                }).ToArray();
+            }
+            else
+            {
+                filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
+                {
+                    Name = i.Item1.Name,
+                    Id = i.Item1.Id
+                }).ToArray();
+            }
+
+            return filters;
+        }
+    }
+}

+ 229 - 0
Jellyfin.Api/Controllers/ImageByNameController.cs

@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    ///     Images By Name Controller.
+    /// </summary>
+    [Route("Images")]
+    public class ImageByNameController : BaseJellyfinApiController
+    {
+        private readonly IServerApplicationPaths _applicationPaths;
+        private readonly IFileSystem _fileSystem;
+
+        /// <summary>
+        ///     Initializes a new instance of the <see cref="ImageByNameController" /> class.
+        /// </summary>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager" /> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem" /> interface.</param>
+        public ImageByNameController(
+            IServerConfigurationManager serverConfigurationManager,
+            IFileSystem fileSystem)
+        {
+            _applicationPaths = serverConfigurationManager.ApplicationPaths;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        ///     Get all general images.
+        /// </summary>
+        /// <response code="200">Retrieved list of images.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+        [HttpGet("General")]
+        [Authorize]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<ImageByNameInfo>> GetGeneralImages()
+        {
+            return GetImageList(_applicationPaths.GeneralPath, false);
+        }
+
+        /// <summary>
+        ///     Get General Image.
+        /// </summary>
+        /// <param name="name">The name of the image.</param>
+        /// <param name="type">Image Type (primary, backdrop, logo, etc).</param>
+        /// <response code="200">Image stream retrieved.</response>
+        /// <response code="404">Image not found.</response>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        [HttpGet("General/{Name}/{Type}")]
+        [AllowAnonymous]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string name, [FromRoute] string type)
+        {
+            var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase)
+                ? "folder"
+                : type;
+
+            var path = BaseItem.SupportedImageExtensions
+                .Select(i => Path.Combine(_applicationPaths.GeneralPath, name, filename + i))
+                .FirstOrDefault(System.IO.File.Exists);
+
+            if (path == null)
+            {
+                return NotFound();
+            }
+
+            var contentType = MimeTypes.GetMimeType(path);
+            return File(System.IO.File.OpenRead(path), contentType);
+        }
+
+        /// <summary>
+        ///     Get all general images.
+        /// </summary>
+        /// <response code="200">Retrieved list of images.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+        [HttpGet("Ratings")]
+        [Authorize]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<ImageByNameInfo>> GetRatingImages()
+        {
+            return GetImageList(_applicationPaths.RatingsPath, false);
+        }
+
+        /// <summary>
+        ///     Get rating image.
+        /// </summary>
+        /// <param name="theme">The theme to get the image from.</param>
+        /// <param name="name">The name of the image.</param>
+        /// <response code="200">Image stream retrieved.</response>
+        /// <response code="404">Image not found.</response>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        [HttpGet("Ratings/{Theme}/{Name}")]
+        [AllowAnonymous]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<FileStreamResult> GetRatingImage(
+            [FromRoute] string theme,
+            [FromRoute] string name)
+        {
+            return GetImageFile(_applicationPaths.RatingsPath, theme, name);
+        }
+
+        /// <summary>
+        ///     Get all media info images.
+        /// </summary>
+        /// <response code="200">Image list retrieved.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+        [HttpGet("MediaInfo")]
+        [Authorize]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<ImageByNameInfo>> GetMediaInfoImages()
+        {
+            return GetImageList(_applicationPaths.MediaInfoImagesPath, false);
+        }
+
+        /// <summary>
+        ///     Get media info image.
+        /// </summary>
+        /// <param name="theme">The theme to get the image from.</param>
+        /// <param name="name">The name of the image.</param>
+        /// <response code="200">Image stream retrieved.</response>
+        /// <response code="404">Image not found.</response>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        [HttpGet("MediaInfo/{Theme}/{Name}")]
+        [AllowAnonymous]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<FileStreamResult> GetMediaInfoImage(
+            [FromRoute] string theme,
+            [FromRoute] string name)
+        {
+            return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name);
+        }
+
+        /// <summary>
+        ///     Internal FileHelper.
+        /// </summary>
+        /// <param name="basePath">Path to begin search.</param>
+        /// <param name="theme">Theme to search.</param>
+        /// <param name="name">File name to search for.</param>
+        /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+        private ActionResult<FileStreamResult> GetImageFile(string basePath, string theme, string name)
+        {
+            var themeFolder = Path.Combine(basePath, theme);
+            if (Directory.Exists(themeFolder))
+            {
+                var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(themeFolder, name + i))
+                    .FirstOrDefault(System.IO.File.Exists);
+
+                if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
+                {
+                    var contentType = MimeTypes.GetMimeType(path);
+                    return File(System.IO.File.OpenRead(path), contentType);
+                }
+            }
+
+            var allFolder = Path.Combine(basePath, "all");
+            if (Directory.Exists(allFolder))
+            {
+                var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(allFolder, name + i))
+                    .FirstOrDefault(System.IO.File.Exists);
+
+                if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
+                {
+                    var contentType = MimeTypes.GetMimeType(path);
+                    return File(System.IO.File.OpenRead(path), contentType);
+                }
+            }
+
+            return NotFound();
+        }
+
+        private List<ImageByNameInfo> GetImageList(string path, bool supportsThemes)
+        {
+            try
+            {
+                return _fileSystem.GetFiles(path, BaseItem.SupportedImageExtensions, false, true)
+                    .Select(i => new ImageByNameInfo
+                    {
+                        Name = _fileSystem.GetFileNameWithoutExtension(i),
+                        FileLength = i.Length,
+
+                        // For themeable images, use the Theme property
+                        // For general images, the same object structure is fine,
+                        // but it's not owned by a theme, so call it Context
+                        Theme = supportsThemes ? GetThemeName(i.FullName, path) : null,
+                        Context = supportsThemes ? null : GetThemeName(i.FullName, path),
+                        Format = i.Extension.ToLowerInvariant().TrimStart('.')
+                    })
+                    .OrderBy(i => i.Name)
+                    .ToList();
+            }
+            catch (IOException)
+            {
+                return new List<ImageByNameInfo>();
+            }
+        }
+
+        private string? GetThemeName(string path, string rootImagePath)
+        {
+            var parentName = Path.GetDirectoryName(path);
+
+            if (string.Equals(parentName, rootImagePath, StringComparison.OrdinalIgnoreCase))
+            {
+                return null;
+            }
+
+            parentName = Path.GetFileName(parentName);
+
+            return string.Equals(parentName, "all", StringComparison.OrdinalIgnoreCase) ? null : parentName;
+        }
+    }
+}

+ 88 - 0
Jellyfin.Api/Controllers/ItemRefreshController.cs

@@ -0,0 +1,88 @@
+#pragma warning disable CA1801
+
+using System.ComponentModel;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Item Refresh Controller.
+    /// </summary>
+    /// [Authenticated]
+    [Route("/Items")]
+    [Authorize]
+    public class ItemRefreshController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IProviderManager _providerManager;
+        private readonly IFileSystem _fileSystem;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ItemRefreshController"/> class.
+        /// </summary>
+        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+        public ItemRefreshController(
+            ILibraryManager libraryManager,
+            IProviderManager providerManager,
+            IFileSystem fileSystem)
+        {
+            _libraryManager = libraryManager;
+            _providerManager = providerManager;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        /// Refreshes metadata for an item.
+        /// </summary>
+        /// <param name="id">Item id.</param>
+        /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param>
+        /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param>
+        /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param>
+        /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param>
+        /// <param name="recursive">(Unused) Indicates if the refresh should occur recursively.</param>
+        /// <response code="200">Item metadata refresh queued.</response>
+        /// <response code="404">Item to refresh not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+        [HttpPost("{Id}/Refresh")]
+        [Description("Refreshes metadata for an item.")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult Post(
+            [FromRoute] string id,
+            [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
+            [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
+            [FromQuery] bool replaceAllMetadata = false,
+            [FromQuery] bool replaceAllImages = false,
+            [FromQuery] bool recursive = false)
+        {
+            var item = _libraryManager.GetItemById(id);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+            {
+                MetadataRefreshMode = metadataRefreshMode,
+                ImageRefreshMode = imageRefreshMode,
+                ReplaceAllImages = replaceAllImages,
+                ReplaceAllMetadata = replaceAllMetadata,
+                ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh
+                    || imageRefreshMode == MetadataRefreshMode.FullRefresh
+                    || replaceAllImages
+                    || replaceAllMetadata,
+                IsAutomated = false
+            };
+
+            _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
+            return Ok();
+        }
+    }
+}

+ 341 - 0
Jellyfin.Api/Controllers/LibraryStructureController.cs

@@ -0,0 +1,341 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The library structure controller.
+    /// </summary>
+    [Route("/Library/VirtualFolders")]
+    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+    public class LibraryStructureController : BaseJellyfinApiController
+    {
+        private readonly IServerApplicationPaths _appPaths;
+        private readonly ILibraryManager _libraryManager;
+        private readonly ILibraryMonitor _libraryMonitor;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
+        /// </summary>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
+        public LibraryStructureController(
+            IServerConfigurationManager serverConfigurationManager,
+            ILibraryManager libraryManager,
+            ILibraryMonitor libraryMonitor)
+        {
+            _appPaths = serverConfigurationManager.ApplicationPaths;
+            _libraryManager = libraryManager;
+            _libraryMonitor = libraryMonitor;
+        }
+
+        /// <summary>
+        /// Gets all virtual folders.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <response code="200">Virtual folders retrieved.</response>
+        /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns>
+        [HttpGet]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders([FromQuery] string userId)
+        {
+            return _libraryManager.GetVirtualFolders(true);
+        }
+
+        /// <summary>
+        /// Adds a virtual folder.
+        /// </summary>
+        /// <param name="name">The name of the virtual folder.</param>
+        /// <param name="collectionType">The type of the collection.</param>
+        /// <param name="refreshLibrary">Whether to refresh the library.</param>
+        /// <param name="paths">The paths of the virtual folder.</param>
+        /// <param name="libraryOptions">The library options.</param>
+        /// <response code="204">Folder added.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> AddVirtualFolder(
+            [FromQuery] string name,
+            [FromQuery] string collectionType,
+            [FromQuery] bool refreshLibrary,
+            [FromQuery] string[] paths,
+            [FromQuery] LibraryOptions libraryOptions)
+        {
+            libraryOptions ??= new LibraryOptions();
+
+            if (paths != null && paths.Length > 0)
+            {
+                libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo { Path = i }).ToArray();
+            }
+
+            await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Removes a virtual folder.
+        /// </summary>
+        /// <param name="name">The name of the folder.</param>
+        /// <param name="refreshLibrary">Whether to refresh the library.</param>
+        /// <response code="204">Folder removed.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> RemoveVirtualFolder(
+            [FromQuery] string name,
+            [FromQuery] bool refreshLibrary)
+        {
+            await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Renames a virtual folder.
+        /// </summary>
+        /// <param name="name">The name of the virtual folder.</param>
+        /// <param name="newName">The new name.</param>
+        /// <param name="refreshLibrary">Whether to refresh the library.</param>
+        /// <response code="204">Folder renamed.</response>
+        /// <response code="404">Library doesn't exist.</response>
+        /// <response code="409">Library already exists.</response>
+        /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
+        /// <exception cref="ArgumentNullException">The new name may not be null.</exception>
+        [HttpPost("Name")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesResponseType(StatusCodes.Status409Conflict)]
+        public ActionResult RenameVirtualFolder(
+            [FromQuery] string name,
+            [FromQuery] string newName,
+            [FromQuery] bool refreshLibrary)
+        {
+            if (string.IsNullOrWhiteSpace(name))
+            {
+                throw new ArgumentNullException(nameof(name));
+            }
+
+            if (string.IsNullOrWhiteSpace(newName))
+            {
+                throw new ArgumentNullException(nameof(newName));
+            }
+
+            var rootFolderPath = _appPaths.DefaultUserViewsPath;
+
+            var currentPath = Path.Combine(rootFolderPath, name);
+            var newPath = Path.Combine(rootFolderPath, newName);
+
+            if (!Directory.Exists(currentPath))
+            {
+                return NotFound("The media collection does not exist.");
+            }
+
+            if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath))
+            {
+                return Conflict($"The media library already exists at {newPath}.");
+            }
+
+            _libraryMonitor.Stop();
+
+            try
+            {
+                // Changing capitalization. Handle windows case insensitivity
+                if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase))
+                {
+                    var tempPath = Path.Combine(
+                        rootFolderPath,
+                        Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
+                    Directory.Move(currentPath, tempPath);
+                    currentPath = tempPath;
+                }
+
+                Directory.Move(currentPath, newPath);
+            }
+            finally
+            {
+                CollectionFolder.OnCollectionFolderChange();
+
+                Task.Run(async () =>
+                {
+                    // No need to start if scanning the library because it will handle it
+                    if (refreshLibrary)
+                    {
+                        await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+                    }
+                    else
+                    {
+                        // Need to add a delay here or directory watchers may still pick up the changes
+                        // Have to block here to allow exceptions to bubble
+                        await Task.Delay(1000).ConfigureAwait(false);
+                        _libraryMonitor.Start();
+                    }
+                });
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Add a media path to a library.
+        /// </summary>
+        /// <param name="name">The name of the library.</param>
+        /// <param name="path">The path to add.</param>
+        /// <param name="pathInfo">The path info.</param>
+        /// <param name="refreshLibrary">Whether to refresh the library.</param>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        /// <response code="204">Media path added.</response>
+        /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+        [HttpPost("Paths")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult AddMediaPath(
+            [FromQuery] string name,
+            [FromQuery] string path,
+            [FromQuery] MediaPathInfo pathInfo,
+            [FromQuery] bool refreshLibrary)
+        {
+            if (string.IsNullOrWhiteSpace(name))
+            {
+                throw new ArgumentNullException(nameof(name));
+            }
+
+            _libraryMonitor.Stop();
+
+            try
+            {
+                var mediaPath = pathInfo ?? new MediaPathInfo { Path = path };
+
+                _libraryManager.AddMediaPath(name, mediaPath);
+            }
+            finally
+            {
+                Task.Run(async () =>
+                {
+                    // No need to start if scanning the library because it will handle it
+                    if (refreshLibrary)
+                    {
+                        await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+                    }
+                    else
+                    {
+                        // Need to add a delay here or directory watchers may still pick up the changes
+                        // Have to block here to allow exceptions to bubble
+                        await Task.Delay(1000).ConfigureAwait(false);
+                        _libraryMonitor.Start();
+                    }
+                });
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates a media path.
+        /// </summary>
+        /// <param name="name">The name of the library.</param>
+        /// <param name="pathInfo">The path info.</param>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        /// <response code="204">Media path updated.</response>
+        /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+        [HttpPost("Paths/Update")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult UpdateMediaPath(
+            [FromQuery] string name,
+            [FromQuery] MediaPathInfo pathInfo)
+        {
+            if (string.IsNullOrWhiteSpace(name))
+            {
+                throw new ArgumentNullException(nameof(name));
+            }
+
+            _libraryManager.UpdateMediaPath(name, pathInfo);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Remove a media path.
+        /// </summary>
+        /// <param name="name">The name of the library.</param>
+        /// <param name="path">The path to remove.</param>
+        /// <param name="refreshLibrary">Whether to refresh the library.</param>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        /// <response code="204">Media path removed.</response>
+        /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+        [HttpDelete("Paths")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult RemoveMediaPath(
+            [FromQuery] string name,
+            [FromQuery] string path,
+            [FromQuery] bool refreshLibrary)
+        {
+            if (string.IsNullOrWhiteSpace(name))
+            {
+                throw new ArgumentNullException(nameof(name));
+            }
+
+            _libraryMonitor.Stop();
+
+            try
+            {
+                _libraryManager.RemoveMediaPath(name, path);
+            }
+            finally
+            {
+                Task.Run(async () =>
+                {
+                    // No need to start if scanning the library because it will handle it
+                    if (refreshLibrary)
+                    {
+                        await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+                    }
+                    else
+                    {
+                        // Need to add a delay here or directory watchers may still pick up the changes
+                        // Have to block here to allow exceptions to bubble
+                        await Task.Delay(1000).ConfigureAwait(false);
+                        _libraryMonitor.Start();
+                    }
+                });
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Update library options.
+        /// </summary>
+        /// <param name="id">The library name.</param>
+        /// <param name="libraryOptions">The library options.</param>
+        /// <response code="204">Library updated.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("LibraryOptions")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult UpdateLibraryOptions(
+            [FromQuery] string id,
+            [FromQuery] LibraryOptions libraryOptions)
+        {
+            var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id);
+
+            collectionFolder.UpdateLibraryOptions(libraryOptions);
+            return NoContent();
+        }
+    }
+}

+ 76 - 0
Jellyfin.Api/Controllers/LocalizationController.cs

@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Localization controller.
+    /// </summary>
+    [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+    public class LocalizationController : BaseJellyfinApiController
+    {
+        private readonly ILocalizationManager _localization;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LocalizationController"/> class.
+        /// </summary>
+        /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+        public LocalizationController(ILocalizationManager localization)
+        {
+            _localization = localization;
+        }
+
+        /// <summary>
+        /// Gets known cultures.
+        /// </summary>
+        /// <response code="200">Known cultures returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns>
+        [HttpGet("Cultures")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<CultureDto>> GetCultures()
+        {
+            return Ok(_localization.GetCultures());
+        }
+
+        /// <summary>
+        /// Gets known countries.
+        /// </summary>
+        /// <response code="200">Known countries returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns>
+        [HttpGet("Countries")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<CountryInfo>> GetCountries()
+        {
+            return Ok(_localization.GetCountries());
+        }
+
+        /// <summary>
+        /// Gets known parental ratings.
+        /// </summary>
+        /// <response code="200">Known parental ratings returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns>
+        [HttpGet("ParentalRatings")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings()
+        {
+            return Ok(_localization.GetParentalRatings());
+        }
+
+        /// <summary>
+        /// Gets localization options.
+        /// </summary>
+        /// <response code="200">Localization options returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns>
+        [HttpGet("Options")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions()
+        {
+            return Ok(_localization.GetLocalizationOptions());
+        }
+    }
+}

+ 17 - 14
Jellyfin.Api/Controllers/NotificationsController.cs

@@ -1,4 +1,3 @@
-#nullable enable
 #pragma warning disable CA1801
 
 using System;
@@ -6,6 +5,7 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
 using Jellyfin.Api.Models.NotificationDtos;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Notifications;
 using MediaBrowser.Model.Dto;
@@ -99,10 +99,10 @@ namespace Jellyfin.Api.Controllers
         /// <param name="description">The description of the notification.</param>
         /// <param name="url">The URL of the notification.</param>
         /// <param name="level">The level of the notification.</param>
-        /// <response code="200">Notification sent.</response>
-        /// <returns>An <cref see="OkResult"/>.</returns>
+        /// <response code="204">Notification sent.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
         [HttpPost("Admin")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult CreateAdminNotification(
             [FromQuery] string name,
             [FromQuery] string description,
@@ -115,13 +115,16 @@ namespace Jellyfin.Api.Controllers
                 Description = description,
                 Url = url,
                 Level = level ?? NotificationLevel.Normal,
-                UserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id).ToArray(),
+                UserIds = _userManager.Users
+                    .Where(user => user.HasPermission(PermissionKind.IsAdministrator))
+                    .Select(user => user.Id)
+                    .ToArray(),
                 Date = DateTime.UtcNow,
             };
 
             _notificationManager.SendNotification(notification, CancellationToken.None);
 
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -129,15 +132,15 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="userId">The userID.</param>
         /// <param name="ids">A comma-separated list of the IDs of notifications which should be set as read.</param>
-        /// <response code="200">Notifications set as read.</response>
-        /// <returns>An <cref see="OkResult"/>.</returns>
+        /// <response code="204">Notifications set as read.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
         [HttpPost("{UserID}/Read")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SetRead(
             [FromRoute] string userId,
             [FromQuery] string ids)
         {
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -145,15 +148,15 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="userId">The userID.</param>
         /// <param name="ids">A comma-separated list of the IDs of notifications which should be set as unread.</param>
-        /// <response code="200">Notifications set as unread.</response>
-        /// <returns>An <cref see="OkResult"/>.</returns>
+        /// <response code="204">Notifications set as unread.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
         [HttpPost("{UserID}/Unread")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SetUnread(
             [FromRoute] string userId,
             [FromQuery] string ids)
         {
-            return Ok();
+            return NoContent();
         }
     }
 }

+ 9 - 10
Jellyfin.Api/Controllers/PackageController.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
@@ -18,7 +16,7 @@ namespace Jellyfin.Api.Controllers
     /// Package Controller.
     /// </summary>
     [Route("Packages")]
-    [Authorize]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
     public class PackageController : BaseJellyfinApiController
     {
         private readonly IInstallationManager _installationManager;
@@ -72,11 +70,11 @@ namespace Jellyfin.Api.Controllers
         /// <param name="name">Package name.</param>
         /// <param name="assemblyGuid">GUID of the associated assembly.</param>
         /// <param name="version">Optional version. Defaults to latest version.</param>
-        /// <response code="200">Package found.</response>
+        /// <response code="204">Package found.</response>
         /// <response code="404">Package not found.</response>
-        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
+        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
         [HttpPost("/Installed/{Name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [Authorize(Policy = Policies.RequiresElevation)]
         public async Task<ActionResult> InstallPackage(
@@ -98,23 +96,24 @@ namespace Jellyfin.Api.Controllers
 
             await _installationManager.InstallPackage(package).ConfigureAwait(false);
 
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
         /// Cancels a package installation.
         /// </summary>
         /// <param name="id">Installation Id.</param>
-        /// <response code="200">Installation cancelled.</response>
-        /// <returns>An <see cref="OkResult"/> on successfully cancelling a package installation.</returns>
+        /// <response code="204">Installation cancelled.</response>
+        /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
         [HttpDelete("/Installing/{id}")]
         [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public IActionResult CancelPackageInstallation(
             [FromRoute] [Required] string id)
         {
             _installationManager.CancelInstallation(new Guid(id));
 
-            return Ok();
+            return NoContent();
         }
     }
 }

+ 188 - 0
Jellyfin.Api/Controllers/PluginsController.cs

@@ -0,0 +1,188 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.PluginDtos;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Model.Plugins;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Plugins controller.
+    /// </summary>
+    [Authorize]
+    public class PluginsController : BaseJellyfinApiController
+    {
+        private readonly IApplicationHost _appHost;
+        private readonly IInstallationManager _installationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PluginsController"/> class.
+        /// </summary>
+        /// <param name="appHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
+        /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+        public PluginsController(
+            IApplicationHost appHost,
+            IInstallationManager installationManager)
+        {
+            _appHost = appHost;
+            _installationManager = installationManager;
+        }
+
+        /// <summary>
+        /// Gets a list of currently installed plugins.
+        /// </summary>
+        /// <param name="isAppStoreEnabled">Optional. Unused.</param>
+        /// <response code="200">Installed plugins returned.</response>
+        /// <returns>List of currently installed plugins.</returns>
+        [HttpGet]
+        public ActionResult<IEnumerable<PluginInfo>> GetPlugins([FromRoute] bool? isAppStoreEnabled)
+        {
+            return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo()));
+        }
+
+        /// <summary>
+        /// Uninstalls a plugin.
+        /// </summary>
+        /// <param name="pluginId">Plugin id.</param>
+        /// <response code="200">Plugin uninstalled.</response>
+        /// <response code="404">Plugin not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+        [HttpDelete("{pluginId}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public ActionResult UninstallPlugin([FromRoute] Guid pluginId)
+        {
+            var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
+            if (plugin == null)
+            {
+                return NotFound();
+            }
+
+            _installationManager.UninstallPlugin(plugin);
+            return Ok();
+        }
+
+        /// <summary>
+        /// Gets plugin configuration.
+        /// </summary>
+        /// <param name="pluginId">Plugin id.</param>
+        /// <response code="200">Plugin configuration returned.</response>
+        /// <response code="404">Plugin not found or plugin configuration not found.</response>
+        /// <returns>Plugin configuration.</returns>
+        [HttpGet("{pluginId}/Configuration")]
+        public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute] Guid pluginId)
+        {
+            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            {
+                return NotFound();
+            }
+
+            return plugin.Configuration;
+        }
+
+        /// <summary>
+        /// Updates plugin configuration.
+        /// </summary>
+        /// <remarks>
+        /// Accepts plugin configuration as JSON body.
+        /// </remarks>
+        /// <param name="pluginId">Plugin id.</param>
+        /// <response code="200">Plugin configuration updated.</response>
+        /// <response code="200">Plugin not found or plugin does not have configuration.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration.
+        ///    The task result contains an <see cref="OkResult"/> indicating success, or <see cref="NotFoundResult"/>
+        ///    when plugin not found or plugin doesn't have configuration.
+        /// </returns>
+        [HttpPost("{pluginId}/Configuration")]
+        public async Task<ActionResult> UpdatePluginConfiguration([FromRoute] Guid pluginId)
+        {
+            if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+            {
+                return NotFound();
+            }
+
+            var configuration = (BasePluginConfiguration)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType)
+                .ConfigureAwait(false);
+
+            plugin.UpdateConfiguration(configuration);
+            return Ok();
+        }
+
+        /// <summary>
+        /// Get plugin security info.
+        /// </summary>
+        /// <response code="200">Plugin security info returned.</response>
+        /// <returns>Plugin security info.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpGet("SecurityInfo")]
+        public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo()
+        {
+            return new PluginSecurityInfo
+            {
+                IsMbSupporter = true,
+                SupporterKey = "IAmTotallyLegit"
+            };
+        }
+
+        /// <summary>
+        /// Updates plugin security info.
+        /// </summary>
+        /// <param name="pluginSecurityInfo">Plugin security info.</param>
+        /// <response code="200">Plugin security info updated.</response>
+        /// <returns>An <see cref="OkResult"/>.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpPost("SecurityInfo")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public ActionResult UpdatePluginSecurityInfo([FromBody, BindRequired] PluginSecurityInfo pluginSecurityInfo)
+        {
+            return Ok();
+        }
+
+        /// <summary>
+        /// Gets registration status for a feature.
+        /// </summary>
+        /// <param name="name">Feature name.</param>
+        /// <response code="200">Registration status returned.</response>
+        /// <returns>Mb registration record.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpPost("RegistrationRecords/{name}")]
+        public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string name)
+        {
+            return new MBRegistrationRecord
+            {
+                IsRegistered = true,
+                RegChecked = true,
+                TrialVersion = false,
+                IsValid = true,
+                RegError = false
+            };
+        }
+
+        /// <summary>
+        /// Gets registration status for a feature.
+        /// </summary>
+        /// <param name="name">Feature name.</param>
+        /// <response code="501">Not implemented.</response>
+        /// <returns>Not Implemented.</returns>
+        /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception>
+        [Obsolete("Paid plugins are not supported")]
+        [HttpGet("/Registrations/{name}")]
+        public ActionResult GetRegistration([FromRoute] string name)
+        {
+            // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins,
+            // delete all these registration endpoints. They are only kept for compatibility.
+            throw new NotImplementedException();
+        }
+    }
+}

+ 265 - 0
Jellyfin.Api/Controllers/RemoteImageController.cs

@@ -0,0 +1,265 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Remote Images Controller.
+    /// </summary>
+    [Route("Images")]
+    [Authorize]
+    public class RemoteImageController : BaseJellyfinApiController
+    {
+        private readonly IProviderManager _providerManager;
+        private readonly IServerApplicationPaths _applicationPaths;
+        private readonly IHttpClient _httpClient;
+        private readonly ILibraryManager _libraryManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="RemoteImageController"/> class.
+        /// </summary>
+        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+        /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
+        /// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        public RemoteImageController(
+            IProviderManager providerManager,
+            IServerApplicationPaths applicationPaths,
+            IHttpClient httpClient,
+            ILibraryManager libraryManager)
+        {
+            _providerManager = providerManager;
+            _applicationPaths = applicationPaths;
+            _httpClient = httpClient;
+            _libraryManager = libraryManager;
+        }
+
+        /// <summary>
+        /// Gets available remote images for an item.
+        /// </summary>
+        /// <param name="id">Item Id.</param>
+        /// <param name="type">The image type.</param>
+        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <param name="providerName">Optional. The image provider to use.</param>
+        /// <param name="includeAllLanguages">Optional. Include all languages.</param>
+        /// <response code="200">Remote Images returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>Remote Image Result.</returns>
+        [HttpGet("{Id}/RemoteImages")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
+            [FromRoute] string id,
+            [FromQuery] ImageType? type,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string providerName,
+            [FromQuery] bool includeAllLanguages)
+        {
+            var item = _libraryManager.GetItemById(id);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var images = await _providerManager.GetAvailableRemoteImages(
+                    item,
+                    new RemoteImageQuery(providerName)
+                    {
+                        IncludeAllLanguages = includeAllLanguages,
+                        IncludeDisabledProviders = true,
+                        ImageType = type
+                    }, CancellationToken.None)
+                .ConfigureAwait(false);
+
+            var imageArray = images.ToArray();
+            var allProviders = _providerManager.GetRemoteImageProviderInfo(item);
+            if (type.HasValue)
+            {
+                allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value));
+            }
+
+            var result = new RemoteImageResult
+            {
+                TotalRecordCount = imageArray.Length,
+                Providers = allProviders.Select(o => o.Name)
+                    .Distinct(StringComparer.OrdinalIgnoreCase)
+                    .ToArray()
+            };
+
+            if (startIndex.HasValue)
+            {
+                imageArray = imageArray.Skip(startIndex.Value).ToArray();
+            }
+
+            if (limit.HasValue)
+            {
+                imageArray = imageArray.Take(limit.Value).ToArray();
+            }
+
+            result.Images = imageArray;
+            return result;
+        }
+
+        /// <summary>
+        /// Gets available remote image providers for an item.
+        /// </summary>
+        /// <param name="id">Item Id.</param>
+        /// <response code="200">Returned remote image providers.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>List of remote image providers.</returns>
+        [HttpGet("{Id}/RemoteImages/Providers")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] string id)
+        {
+            var item = _libraryManager.GetItemById(id);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return Ok(_providerManager.GetRemoteImageProviderInfo(item));
+        }
+
+        /// <summary>
+        /// Gets a remote image.
+        /// </summary>
+        /// <param name="imageUrl">The image url.</param>
+        /// <response code="200">Remote image returned.</response>
+        /// <response code="404">Remote image not found.</response>
+        /// <returns>Image Stream.</returns>
+        [HttpGet("Remote")]
+        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult<FileStreamResult>> GetRemoteImage([FromQuery, BindRequired] string imageUrl)
+        {
+            var urlHash = imageUrl.GetMD5();
+            var pointerCachePath = GetFullCachePath(urlHash.ToString());
+
+            string? contentPath = null;
+            var hasFile = false;
+
+            try
+            {
+                contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+                if (System.IO.File.Exists(contentPath))
+                {
+                    hasFile = true;
+                }
+            }
+            catch (FileNotFoundException)
+            {
+                // The file isn't cached yet
+            }
+            catch (IOException)
+            {
+                // The file isn't cached yet
+            }
+
+            if (!hasFile)
+            {
+                await DownloadImage(imageUrl, urlHash, pointerCachePath).ConfigureAwait(false);
+                contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+            }
+
+            if (string.IsNullOrEmpty(contentPath))
+            {
+                return NotFound();
+            }
+
+            var contentType = MimeTypes.GetMimeType(contentPath);
+            return File(System.IO.File.OpenRead(contentPath), contentType);
+        }
+
+        /// <summary>
+        /// Downloads a remote image for an item.
+        /// </summary>
+        /// <param name="id">Item Id.</param>
+        /// <param name="type">The image type.</param>
+        /// <param name="imageUrl">The image url.</param>
+        /// <response code="200">Remote image downloaded.</response>
+        /// <response code="404">Remote image not found.</response>
+        /// <returns>Download status.</returns>
+        [HttpPost("{Id}/RemoteImages/Download")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> DownloadRemoteImage(
+            [FromRoute] string id,
+            [FromQuery, BindRequired] ImageType type,
+            [FromQuery] string imageUrl)
+        {
+            var item = _libraryManager.GetItemById(id);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
+                .ConfigureAwait(false);
+
+            item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
+            return Ok();
+        }
+
+        /// <summary>
+        /// Gets the full cache path.
+        /// </summary>
+        /// <param name="filename">The filename.</param>
+        /// <returns>System.String.</returns>
+        private string GetFullCachePath(string filename)
+        {
+            return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename);
+        }
+
+        /// <summary>
+        /// Downloads the image.
+        /// </summary>
+        /// <param name="url">The URL.</param>
+        /// <param name="urlHash">The URL hash.</param>
+        /// <param name="pointerCachePath">The pointer cache path.</param>
+        /// <returns>Task.</returns>
+        private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath)
+        {
+            using var result = await _httpClient.GetResponse(new HttpRequestOptions
+            {
+                Url = url,
+                BufferContent = false
+            }).ConfigureAwait(false);
+            var ext = result.ContentType.Split('/').Last();
+
+            var fullCachePath = GetFullCachePath(urlHash + "." + ext);
+
+            Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
+            await using (var stream = result.Content)
+            {
+                await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
+                await stream.CopyToAsync(fileStream).ConfigureAwait(false);
+            }
+
+            Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));
+            await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None)
+                .ConfigureAwait(false);
+        }
+    }
+}

+ 2 - 1
Jellyfin.Api/Controllers/SearchController.cs

@@ -3,6 +3,7 @@ using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.Linq;
+using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;
@@ -23,7 +24,7 @@ namespace Jellyfin.Api.Controllers
     /// Search controller.
     /// </summary>
     [Route("/Search/Hints")]
-    [Authorize]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
     public class SearchController : BaseJellyfinApiController
     {
         private readonly ISearchEngine _searchEngine;

+ 25 - 19
Jellyfin.Api/Controllers/StartupController.cs

@@ -33,16 +33,16 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Completes the startup wizard.
         /// </summary>
-        /// <response code="200">Startup wizard completed.</response>
-        /// <returns>An <see cref="OkResult"/> indicating success.</returns>
+        /// <response code="204">Startup wizard completed.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Complete")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult CompleteWizard()
         {
             _config.Configuration.IsStartupWizardCompleted = true;
             _config.SetOptimalValues();
             _config.SaveConfiguration();
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -70,10 +70,10 @@ namespace Jellyfin.Api.Controllers
         /// <param name="uiCulture">The UI language culture.</param>
         /// <param name="metadataCountryCode">The metadata country code.</param>
         /// <param name="preferredMetadataLanguage">The preferred language for metadata.</param>
-        /// <response code="200">Configuration saved.</response>
-        /// <returns>An <see cref="OkResult"/> indicating success.</returns>
+        /// <response code="204">Configuration saved.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("Configuration")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult UpdateInitialConfiguration(
             [FromForm] string uiCulture,
             [FromForm] string metadataCountryCode,
@@ -83,7 +83,7 @@ namespace Jellyfin.Api.Controllers
             _config.Configuration.MetadataCountryCode = metadataCountryCode;
             _config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage;
             _config.SaveConfiguration();
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -91,16 +91,16 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="enableRemoteAccess">Enable remote access.</param>
         /// <param name="enableAutomaticPortMapping">Enable UPnP.</param>
-        /// <response code="200">Configuration saved.</response>
-        /// <returns>An <see cref="OkResult"/> indicating success.</returns>
+        /// <response code="204">Configuration saved.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("RemoteAccess")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
         {
             _config.Configuration.EnableRemoteAccess = enableRemoteAccess;
             _config.Configuration.EnableUPnP = enableAutomaticPortMapping;
             _config.SaveConfiguration();
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -113,35 +113,41 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<StartupUserDto> GetFirstUser()
         {
+            // TODO: Remove this method when startup wizard no longer requires an existing user.
+            _userManager.Initialize();
             var user = _userManager.Users.First();
-            return new StartupUserDto { Name = user.Name, Password = user.Password };
+            return new StartupUserDto
+            {
+                Name = user.Username,
+                Password = user.Password
+            };
         }
 
         /// <summary>
         /// Sets the user name and password.
         /// </summary>
         /// <param name="startupUserDto">The DTO containing username and password.</param>
-        /// <response code="200">Updated user name and password.</response>
+        /// <response code="204">Updated user name and password.</response>
         /// <returns>
         /// A <see cref="Task" /> that represents the asynchronous update operation.
-        /// The task result contains an <see cref="OkResult"/> indicating success.
+        /// The task result contains a <see cref="NoContentResult"/> indicating success.
         /// </returns>
         [HttpPost("User")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> UpdateUser([FromForm] StartupUserDto startupUserDto)
         {
             var user = _userManager.Users.First();
 
-            user.Name = startupUserDto.Name;
+            user.Username = startupUserDto.Name;
 
-            _userManager.UpdateUser(user);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
             if (!string.IsNullOrEmpty(startupUserDto.Password))
             {
                 await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
             }
 
-            return Ok();
+            return NoContent();
         }
     }
 }

+ 348 - 0
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -0,0 +1,348 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Subtitle controller.
+    /// </summary>
+    public class SubtitleController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly ISubtitleManager _subtitleManager;
+        private readonly ISubtitleEncoder _subtitleEncoder;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IProviderManager _providerManager;
+        private readonly IFileSystem _fileSystem;
+        private readonly IAuthorizationContext _authContext;
+        private readonly ILogger<SubtitleController> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SubtitleController"/> class.
+        /// </summary>
+        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
+        /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+        /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
+        public SubtitleController(
+            ILibraryManager libraryManager,
+            ISubtitleManager subtitleManager,
+            ISubtitleEncoder subtitleEncoder,
+            IMediaSourceManager mediaSourceManager,
+            IProviderManager providerManager,
+            IFileSystem fileSystem,
+            IAuthorizationContext authContext,
+            ILogger<SubtitleController> logger)
+        {
+            _libraryManager = libraryManager;
+            _subtitleManager = subtitleManager;
+            _subtitleEncoder = subtitleEncoder;
+            _mediaSourceManager = mediaSourceManager;
+            _providerManager = providerManager;
+            _fileSystem = fileSystem;
+            _authContext = authContext;
+            _logger = logger;
+        }
+
+        /// <summary>
+        /// Deletes an external subtitle file.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="index">The index of the subtitle file.</param>
+        /// <response code="204">Subtitle deleted.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("/Videos/{id}/Subtitles/{index}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<Task> DeleteSubtitle(
+            [FromRoute] Guid id,
+            [FromRoute] int index)
+        {
+            var item = _libraryManager.GetItemById(id);
+
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            _subtitleManager.DeleteSubtitles(item, index);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Search remote subtitles.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="language">The language of the subtitles.</param>
+        /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param>
+        /// <response code="200">Subtitles retrieved.</response>
+        /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns>
+        [HttpGet("/Items/{id}/RemoteSearch/Subtitles/{language}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
+            [FromRoute] Guid id,
+            [FromRoute] string language,
+            [FromQuery] bool? isPerfectMatch)
+        {
+            var video = (Video)_libraryManager.GetItemById(id);
+
+            return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Downloads a remote subtitle.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="subtitleId">The subtitle id.</param>
+        /// <response code="204">Subtitle downloaded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Items/{id}/RemoteSearch/Subtitles/{subtitleId}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> DownloadRemoteSubtitles(
+            [FromRoute] Guid id,
+            [FromRoute] string subtitleId)
+        {
+            var video = (Video)_libraryManager.GetItemById(id);
+
+            try
+            {
+                await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None)
+                    .ConfigureAwait(false);
+
+                _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error downloading subtitles");
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Gets the remote subtitles.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <response code="200">File returned.</response>
+        /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
+        [HttpGet("/Providers/Subtitles/Subtitles/{id}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [Produces(MediaTypeNames.Application.Octet)]
+        public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string id)
+        {
+            var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
+
+            return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
+        }
+
+        /// <summary>
+        /// Gets subtitles in a specified format.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="mediaSourceId">The media source id.</param>
+        /// <param name="index">The subtitle stream index.</param>
+        /// <param name="format">The format of the returned subtitle.</param>
+        /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
+        /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
+        /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
+        /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
+        /// <response code="200">File returned.</response>
+        /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+        [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
+        [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> GetSubtitle(
+            [FromRoute, Required] Guid id,
+            [FromRoute, Required] string mediaSourceId,
+            [FromRoute, Required] int index,
+            [FromRoute, Required] string format,
+            [FromRoute] long startPositionTicks,
+            [FromQuery] long? endPositionTicks,
+            [FromQuery] bool copyTimestamps,
+            [FromQuery] bool addVttTimeMap)
+        {
+            if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
+            {
+                format = "json";
+            }
+
+            if (string.IsNullOrEmpty(format))
+            {
+                var item = (Video)_libraryManager.GetItemById(id);
+
+                var idString = id.ToString("N", CultureInfo.InvariantCulture);
+                var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
+                    .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal));
+
+                var subtitleStream = mediaSource.MediaStreams
+                    .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
+
+                FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read);
+                return File(stream, MimeTypes.GetMimeType(subtitleStream.Path));
+            }
+
+            if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
+            {
+                await using Stream stream = await EncodeSubtitles(id, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
+                using var reader = new StreamReader(stream);
+
+                var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+                text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
+
+                return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
+            }
+
+            return File(
+                await EncodeSubtitles(
+                    id,
+                    mediaSourceId,
+                    index,
+                    format,
+                    startPositionTicks,
+                    endPositionTicks,
+                    copyTimestamps).ConfigureAwait(false),
+                MimeTypes.GetMimeType("file." + format));
+        }
+
+        /// <summary>
+        /// Gets an HLS subtitle playlist.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="index">The subtitle stream index.</param>
+        /// <param name="mediaSourceId">The media source id.</param>
+        /// <param name="segmentLength">The subtitle segment length.</param>
+        /// <response code="200">Subtitle playlist retrieved.</response>
+        /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns>
+        [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> GetSubtitlePlaylist(
+            [FromRoute] Guid id,
+            // TODO: 'int index' is never used: CA1801 is disabled
+            [FromRoute] int index,
+            [FromRoute] string mediaSourceId,
+            [FromQuery, Required] int segmentLength)
+        {
+            var item = (Video)_libraryManager.GetItemById(id);
+
+            var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
+
+            var builder = new StringBuilder();
+
+            var runtime = mediaSource.RunTimeTicks ?? -1;
+
+            if (runtime <= 0)
+            {
+                throw new ArgumentException("HLS Subtitles are not supported for this media.");
+            }
+
+            var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks;
+            if (segmentLengthTicks <= 0)
+            {
+                throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)");
+            }
+
+            builder.AppendLine("#EXTM3U");
+            builder.AppendLine("#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture));
+            builder.AppendLine("#EXT-X-VERSION:3");
+            builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
+            builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
+
+            long positionTicks = 0;
+
+            var accessToken = _authContext.GetAuthorizationInfo(Request).Token;
+
+            while (positionTicks < runtime)
+            {
+                var remaining = runtime - positionTicks;
+                var lengthTicks = Math.Min(remaining, segmentLengthTicks);
+
+                builder.AppendLine("#EXTINF:" + TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture) + ",");
+
+                var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
+
+                var url = string.Format(
+                    CultureInfo.CurrentCulture,
+                    "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}",
+                    positionTicks.ToString(CultureInfo.InvariantCulture),
+                    endPositionTicks.ToString(CultureInfo.InvariantCulture),
+                    accessToken);
+
+                builder.AppendLine(url);
+
+                positionTicks += segmentLengthTicks;
+            }
+
+            builder.AppendLine("#EXT-X-ENDLIST");
+            return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+        }
+
+        /// <summary>
+        /// Encodes a subtitle in the specified format.
+        /// </summary>
+        /// <param name="id">The media id.</param>
+        /// <param name="mediaSourceId">The source media id.</param>
+        /// <param name="index">The subtitle index.</param>
+        /// <param name="format">The format to convert to.</param>
+        /// <param name="startPositionTicks">The start position in ticks.</param>
+        /// <param name="endPositionTicks">The end position in ticks.</param>
+        /// <param name="copyTimestamps">Whether to copy the timestamps.</param>
+        /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns>
+        private Task<Stream> EncodeSubtitles(
+            Guid id,
+            string mediaSourceId,
+            int index,
+            string format,
+            long startPositionTicks,
+            long? endPositionTicks,
+            bool copyTimestamps)
+        {
+            var item = _libraryManager.GetItemById(id);
+
+            return _subtitleEncoder.GetSubtitles(
+                item,
+                mediaSourceId,
+                index,
+                format,
+                startPositionTicks,
+                endPositionTicks ?? 0,
+                copyTimestamps,
+                CancellationToken.None);
+        }
+    }
+}

+ 2 - 3
Jellyfin.Api/Controllers/VideoAttachmentsController.cs

@@ -1,9 +1,8 @@
-#nullable enable
-
 using System;
 using System.Net.Mime;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
@@ -17,7 +16,7 @@ namespace Jellyfin.Api.Controllers
     /// Attachments controller.
     /// </summary>
     [Route("Videos")]
-    [Authorize]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
     public class VideoAttachmentsController : BaseJellyfinApiController
     {
         private readonly ILibraryManager _libraryManager;

+ 77 - 0
Jellyfin.Api/Helpers/ClaimHelpers.cs

@@ -0,0 +1,77 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using System.Security.Claims;
+using Jellyfin.Api.Constants;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// Claim Helpers.
+    /// </summary>
+    public static class ClaimHelpers
+    {
+        /// <summary>
+        /// Get user id from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>User id.</returns>
+        public static Guid? GetUserId(in ClaimsPrincipal user)
+        {
+            var value = GetClaimValue(user, InternalClaimTypes.UserId);
+            return string.IsNullOrEmpty(value)
+                ? null
+                : (Guid?)Guid.Parse(value);
+        }
+
+        /// <summary>
+        /// Get device id from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Device id.</returns>
+        public static string? GetDeviceId(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.DeviceId);
+
+        /// <summary>
+        /// Get device from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Device.</returns>
+        public static string? GetDevice(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Device);
+
+        /// <summary>
+        /// Get client from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Client.</returns>
+        public static string? GetClient(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Client);
+
+        /// <summary>
+        /// Get version from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Version.</returns>
+        public static string? GetVersion(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Version);
+
+        /// <summary>
+        /// Get token from claims.
+        /// </summary>
+        /// <param name="user">Current claims principal.</param>
+        /// <returns>Token.</returns>
+        public static string? GetToken(in ClaimsPrincipal user)
+            => GetClaimValue(user, InternalClaimTypes.Token);
+
+        private static string? GetClaimValue(in ClaimsPrincipal user, string name)
+        {
+            return user?.Identities
+                .SelectMany(c => c.Claims)
+                .Where(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase))
+                .Select(claim => claim.Value)
+                .FirstOrDefault();
+        }
+    }
+}

+ 0 - 2
Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 namespace Jellyfin.Api.Models.ConfigurationDtos
 {
     /// <summary>

+ 0 - 2
Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using MediaBrowser.Model.Notifications;
 

+ 0 - 2
Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using System;
 using System.Collections.Generic;
 

+ 0 - 2
Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs

@@ -1,5 +1,3 @@
-#nullable enable
-
 using MediaBrowser.Model.Notifications;
 
 namespace Jellyfin.Api.Models.NotificationDtos

+ 40 - 0
Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs

@@ -0,0 +1,40 @@
+using System;
+
+namespace Jellyfin.Api.Models.PluginDtos
+{
+    /// <summary>
+    /// MB Registration Record.
+    /// </summary>
+    public class MBRegistrationRecord
+    {
+        /// <summary>
+        /// Gets or sets expiration date.
+        /// </summary>
+        public DateTime ExpirationDate { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether is registered.
+        /// </summary>
+        public bool IsRegistered { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether reg checked.
+        /// </summary>
+        public bool RegChecked { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether reg error.
+        /// </summary>
+        public bool RegError { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether trial version.
+        /// </summary>
+        public bool TrialVersion { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether is valid.
+        /// </summary>
+        public bool IsValid { get; set; }
+    }
+}

+ 18 - 0
Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Api.Models.PluginDtos
+{
+    /// <summary>
+    /// Plugin security info.
+    /// </summary>
+    public class PluginSecurityInfo
+    {
+        /// <summary>
+        /// Gets or sets the supporter key.
+        /// </summary>
+        public string? SupporterKey { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether is mb supporter.
+        /// </summary>
+        public bool IsMbSupporter { get; set; }
+    }
+}

+ 3 - 5
Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 namespace Jellyfin.Api.Models.StartupDtos
 {
     /// <summary>
@@ -10,16 +8,16 @@ namespace Jellyfin.Api.Models.StartupDtos
         /// <summary>
         /// Gets or sets UI language culture.
         /// </summary>
-        public string UICulture { get; set; }
+        public string? UICulture { get; set; }
 
         /// <summary>
         /// Gets or sets the metadata country code.
         /// </summary>
-        public string MetadataCountryCode { get; set; }
+        public string? MetadataCountryCode { get; set; }
 
         /// <summary>
         /// Gets or sets the preferred language for the metadata.
         /// </summary>
-        public string PreferredMetadataLanguage { get; set; }
+        public string? PreferredMetadataLanguage { get; set; }
     }
 }

+ 2 - 4
Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 namespace Jellyfin.Api.Models.StartupDtos
 {
     /// <summary>
@@ -10,11 +8,11 @@ namespace Jellyfin.Api.Models.StartupDtos
         /// <summary>
         /// Gets or sets the username.
         /// </summary>
-        public string Name { get; set; }
+        public string? Name { get; set; }
 
         /// <summary>
         /// Gets or sets the user's password.
         /// </summary>
-        public string Password { get; set; }
+        public string? Password { get; set; }
     }
 }

+ 65 - 0
Jellyfin.Data/DayOfWeekHelper.cs

@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using Jellyfin.Data.Enums;
+
+namespace Jellyfin.Data
+{
+    public static class DayOfWeekHelper
+    {
+        public static List<DayOfWeek> GetDaysOfWeek(DynamicDayOfWeek day)
+        {
+            var days = new List<DayOfWeek>(7);
+
+            if (day == DynamicDayOfWeek.Sunday
+                || day == DynamicDayOfWeek.Weekend
+                || day == DynamicDayOfWeek.Everyday)
+            {
+                days.Add(DayOfWeek.Sunday);
+            }
+
+            if (day == DynamicDayOfWeek.Monday
+                || day == DynamicDayOfWeek.Weekday
+                || day == DynamicDayOfWeek.Everyday)
+            {
+                days.Add(DayOfWeek.Monday);
+            }
+
+            if (day == DynamicDayOfWeek.Tuesday
+                || day == DynamicDayOfWeek.Weekday
+                || day == DynamicDayOfWeek.Everyday)
+            {
+                days.Add(DayOfWeek.Tuesday);
+            }
+
+            if (day == DynamicDayOfWeek.Wednesday
+                || day == DynamicDayOfWeek.Weekday
+                || day == DynamicDayOfWeek.Everyday)
+            {
+                days.Add(DayOfWeek.Wednesday);
+            }
+
+            if (day == DynamicDayOfWeek.Thursday
+                || day == DynamicDayOfWeek.Weekday
+                || day == DynamicDayOfWeek.Everyday)
+            {
+                days.Add(DayOfWeek.Thursday);
+            }
+
+            if (day == DynamicDayOfWeek.Friday
+                || day == DynamicDayOfWeek.Weekday
+                || day == DynamicDayOfWeek.Everyday)
+            {
+                days.Add(DayOfWeek.Friday);
+            }
+
+            if (day == DynamicDayOfWeek.Saturday
+                || day == DynamicDayOfWeek.Weekend
+                || day == DynamicDayOfWeek.Everyday)
+            {
+                days.Add(DayOfWeek.Saturday);
+            }
+
+            return days;
+        }
+    }
+}

+ 91 - 0
Jellyfin.Data/Entities/AccessSchedule.cs

@@ -0,0 +1,91 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.Json.Serialization;
+using System.Xml.Serialization;
+using Jellyfin.Data.Enums;
+
+namespace Jellyfin.Data.Entities
+{
+    /// <summary>
+    /// An entity representing a user's access schedule.
+    /// </summary>
+    public class AccessSchedule
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AccessSchedule"/> class.
+        /// </summary>
+        /// <param name="dayOfWeek">The day of the week.</param>
+        /// <param name="startHour">The start hour.</param>
+        /// <param name="endHour">The end hour.</param>
+        /// <param name="userId">The associated user's id.</param>
+        public AccessSchedule(DynamicDayOfWeek dayOfWeek, double startHour, double endHour, Guid userId)
+        {
+            UserId = userId;
+            DayOfWeek = dayOfWeek;
+            StartHour = startHour;
+            EndHour = endHour;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AccessSchedule"/> class.
+        /// Default constructor. Protected due to required properties, but present because EF needs it.
+        /// </summary>
+        protected AccessSchedule()
+        {
+        }
+
+        /// <summary>
+        /// Gets or sets the id of this instance.
+        /// </summary>
+        /// <remarks>
+        /// Identity, Indexed, Required.
+        /// </remarks>
+        [XmlIgnore]
+        [Key]
+        [Required]
+        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+        public int Id { get; protected set; }
+
+        /// <summary>
+        /// Gets or sets the id of the associated user.
+        /// </summary>
+        [XmlIgnore]
+        [Required]
+        public Guid UserId { get; protected set; }
+
+        /// <summary>
+        /// Gets or sets the day of week.
+        /// </summary>
+        /// <value>The day of week.</value>
+        [Required]
+        public DynamicDayOfWeek DayOfWeek { get; set; }
+
+        /// <summary>
+        /// Gets or sets the start hour.
+        /// </summary>
+        /// <value>The start hour.</value>
+        [Required]
+        public double StartHour { get; set; }
+
+        /// <summary>
+        /// Gets or sets the end hour.
+        /// </summary>
+        /// <value>The end hour.</value>
+        [Required]
+        public double EndHour { get; set; }
+
+        /// <summary>
+        /// Static create function (for use in LINQ queries, etc.)
+        /// </summary>
+        /// <param name="dayOfWeek">The day of the week.</param>
+        /// <param name="startHour">The start hour.</param>
+        /// <param name="endHour">The end hour.</param>
+        /// <param name="userId">The associated user's id.</param>
+        /// <returns>The newly created instance.</returns>
+        public static AccessSchedule Create(DynamicDayOfWeek dayOfWeek, double startHour, double endHour, Guid userId)
+        {
+            return new AccessSchedule(dayOfWeek, startHour, endHour, userId);
+        }
+    }
+}

+ 58 - 45
Jellyfin.Data/Entities/Group.cs

@@ -2,19 +2,32 @@ using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations.Schema;
+using System.Linq;
+using Jellyfin.Data.Enums;
 
 namespace Jellyfin.Data.Entities
 {
-    public partial class Group
+    /// <summary>
+    /// An entity representing a group.
+    /// </summary>
+    public partial class Group : IHasPermissions, ISavingChanges
     {
-        partial void Init();
-
         /// <summary>
-        /// Default constructor. Protected due to required properties, but present because EF needs it.
+        /// Initializes a new instance of the <see cref="Group"/> class.
+        /// Public constructor with required data.
         /// </summary>
-        protected Group()
+        /// <param name="name">The name of the group.</param>
+        public Group(string name)
         {
-            GroupPermissions = new HashSet<Permission>();
+            if (string.IsNullOrEmpty(name))
+            {
+                throw new ArgumentNullException(nameof(name));
+            }
+
+            Name = name;
+            Id = Guid.NewGuid();
+
+            Permissions = new HashSet<Permission>();
             ProviderMappings = new HashSet<ProviderMapping>();
             Preferences = new HashSet<Preference>();
 
@@ -22,66 +35,45 @@ namespace Jellyfin.Data.Entities
         }
 
         /// <summary>
-        /// Replaces default constructor, since it's protected. Caller assumes responsibility for setting all required values before saving.
-        /// </summary>
-        public static Group CreateGroupUnsafe()
-        {
-            return new Group();
-        }
-
-        /// <summary>
-        /// Public constructor with required data
+        /// Initializes a new instance of the <see cref="Group"/> class.
+        /// Default constructor. Protected due to required properties, but present because EF needs it.
         /// </summary>
-        /// <param name="name"></param>
-        /// <param name="_user0"></param>
-        public Group(string name, User _user0)
+        protected Group()
         {
-            if (string.IsNullOrEmpty(name)) throw new ArgumentNullException(nameof(name));
-            this.Name = name;
-
-            if (_user0 == null) throw new ArgumentNullException(nameof(_user0));
-            _user0.Groups.Add(this);
-
-            this.GroupPermissions = new HashSet<Permission>();
-            this.ProviderMappings = new HashSet<ProviderMapping>();
-            this.Preferences = new HashSet<Preference>();
-
             Init();
         }
 
-        /// <summary>
-        /// Static create function (for use in LINQ queries, etc.)
-        /// </summary>
-        /// <param name="name"></param>
-        /// <param name="_user0"></param>
-        public static Group Create(string name, User _user0)
-        {
-            return new Group(name, _user0);
-        }
-
         /*************************************************************************
          * Properties
          *************************************************************************/
 
         /// <summary>
-        /// Identity, Indexed, Required
+        /// Gets or sets the id of this group.
         /// </summary>
+        /// <remarks>
+        /// Identity, Indexed, Required.
+        /// </remarks>
         [Key]
         [Required]
-        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
-        public int Id { get; protected set; }
+        public Guid Id { get; protected set; }
 
         /// <summary>
-        /// Required, Max length = 255
+        /// Gets or sets the group's name.
         /// </summary>
+        /// <remarks>
+        /// Required, Max length = 255.
+        /// </remarks>
         [Required]
         [MaxLength(255)]
         [StringLength(255)]
         public string Name { get; set; }
 
         /// <summary>
-        /// Required, ConcurrenyToken
+        /// Gets or sets the row version.
         /// </summary>
+        /// <remarks>
+        /// Required, Concurrency Token.
+        /// </remarks>
         [ConcurrencyCheck]
         [Required]
         public uint RowVersion { get; set; }
@@ -96,7 +88,7 @@ namespace Jellyfin.Data.Entities
          *************************************************************************/
 
         [ForeignKey("Permission_GroupPermissions_Id")]
-        public virtual ICollection<Permission> GroupPermissions { get; protected set; }
+        public virtual ICollection<Permission> Permissions { get; protected set; }
 
         [ForeignKey("ProviderMapping_ProviderMappings_Id")]
         public virtual ICollection<ProviderMapping> ProviderMappings { get; protected set; }
@@ -104,6 +96,27 @@ namespace Jellyfin.Data.Entities
         [ForeignKey("Preference_Preferences_Id")]
         public virtual ICollection<Preference> Preferences { get; protected set; }
 
+        /// <summary>
+        /// Static create function (for use in LINQ queries, etc.)
+        /// </summary>
+        /// <param name="name">The name of this group</param>
+        public static Group Create(string name)
+        {
+            return new Group(name);
+        }
+
+        /// <inheritdoc/>
+        public bool HasPermission(PermissionKind kind)
+        {
+            return Permissions.First(p => p.Kind == kind).Value;
+        }
+
+        /// <inheritdoc/>
+        public void SetPermission(PermissionKind kind, bool value)
+        {
+            Permissions.First(p => p.Kind == kind).Value = value;
+        }
+
+        partial void Init();
     }
 }
-

+ 30 - 0
Jellyfin.Data/Entities/ImageInfo.cs

@@ -0,0 +1,30 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Jellyfin.Data.Entities
+{
+    public class ImageInfo
+    {
+        public ImageInfo(string path)
+        {
+            Path = path;
+            LastModified = DateTime.UtcNow;
+        }
+
+        [Key]
+        [Required]
+        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+        public int Id { get; protected set; }
+
+        public Guid? UserId { get; protected set; }
+
+        [Required]
+        [MaxLength(512)]
+        [StringLength(512)]
+        public string Path { get; set; }
+
+        [Required]
+        public DateTime LastModified { get; set; }
+    }
+}

+ 45 - 92
Jellyfin.Data/Entities/Permission.cs

@@ -1,64 +1,35 @@
-using System;
-using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations.Schema;
-using System.Runtime.CompilerServices;
+using Jellyfin.Data.Enums;
 
 namespace Jellyfin.Data.Entities
 {
-    public partial class Permission
+    /// <summary>
+    /// An entity representing whether the associated user has a specific permission.
+    /// </summary>
+    public partial class Permission : ISavingChanges
     {
-        partial void Init();
-
-        /// <summary>
-        /// Default constructor. Protected due to required properties, but present because EF needs it.
-        /// </summary>
-        protected Permission()
-        {
-            Init();
-        }
-
         /// <summary>
-        /// Replaces default constructor, since it's protected. Caller assumes responsibility for setting all required values before saving.
+        /// Initializes a new instance of the <see cref="Permission"/> class.
+        /// Public constructor with required data.
         /// </summary>
-        public static Permission CreatePermissionUnsafe()
+        /// <param name="kind">The permission kind.</param>
+        /// <param name="value">The value of this permission.</param>
+        public Permission(PermissionKind kind, bool value)
         {
-            return new Permission();
-        }
-
-        /// <summary>
-        /// Public constructor with required data
-        /// </summary>
-        /// <param name="kind"></param>
-        /// <param name="value"></param>
-        /// <param name="_user0"></param>
-        /// <param name="_group1"></param>
-        public Permission(Enums.PermissionKind kind, bool value, User _user0, Group _group1)
-        {
-            this.Kind = kind;
-
-            this.Value = value;
-
-            if (_user0 == null) throw new ArgumentNullException(nameof(_user0));
-            _user0.Permissions.Add(this);
-
-            if (_group1 == null) throw new ArgumentNullException(nameof(_group1));
-            _group1.GroupPermissions.Add(this);
-
+            Kind = kind;
+            Value = value;
 
             Init();
         }
 
         /// <summary>
-        /// Static create function (for use in LINQ queries, etc.)
+        /// Initializes a new instance of the <see cref="Permission"/> class.
+        /// Default constructor. Protected due to required properties, but present because EF needs it.
         /// </summary>
-        /// <param name="kind"></param>
-        /// <param name="value"></param>
-        /// <param name="_user0"></param>
-        /// <param name="_group1"></param>
-        public static Permission Create(Enums.PermissionKind kind, bool value, User _user0, Group _group1)
+        protected Permission()
         {
-            return new Permission(kind, value, _user0, _group1);
+            Init();
         }
 
         /*************************************************************************
@@ -66,79 +37,61 @@ namespace Jellyfin.Data.Entities
          *************************************************************************/
 
         /// <summary>
-        /// Identity, Indexed, Required
+        /// Gets or sets the id of this permission.
         /// </summary>
+        /// <remarks>
+        /// Identity, Indexed, Required.
+        /// </remarks>
         [Key]
         [Required]
         [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
         public int Id { get; protected set; }
 
         /// <summary>
-        /// Backing field for Kind
-        /// </summary>
-        protected Enums.PermissionKind _Kind;
-        /// <summary>
-        /// When provided in a partial class, allows value of Kind to be changed before setting.
-        /// </summary>
-        partial void SetKind(Enums.PermissionKind oldValue, ref Enums.PermissionKind newValue);
-        /// <summary>
-        /// When provided in a partial class, allows value of Kind to be changed before returning.
-        /// </summary>
-        partial void GetKind(ref Enums.PermissionKind result);
-
-        /// <summary>
-        /// Required
+        /// Gets or sets the type of this permission.
         /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
         [Required]
-        public Enums.PermissionKind Kind
-        {
-            get
-            {
-                Enums.PermissionKind value = _Kind;
-                GetKind(ref value);
-                return (_Kind = value);
-            }
-            set
-            {
-                Enums.PermissionKind oldValue = _Kind;
-                SetKind(oldValue, ref value);
-                if (oldValue != value)
-                {
-                    _Kind = value;
-                    OnPropertyChanged();
-                }
-            }
-        }
+        public PermissionKind Kind { get; protected set; }
 
         /// <summary>
-        /// Required
+        /// Gets or sets a value indicating whether the associated user has this permission.
         /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
         [Required]
         public bool Value { get; set; }
 
         /// <summary>
-        /// Required, ConcurrenyToken
+        /// Gets or sets the row version.
         /// </summary>
+        /// <remarks>
+        /// Required, ConcurrencyToken.
+        /// </remarks>
         [ConcurrencyCheck]
         [Required]
         public uint RowVersion { get; set; }
 
-        public void OnSavingChanges()
+        /// <summary>
+        /// Static create function (for use in LINQ queries, etc.)
+        /// </summary>
+        /// <param name="kind">The permission kind.</param>
+        /// <param name="value">The value of this permission.</param>
+        /// <returns>The newly created instance.</returns>
+        public static Permission Create(PermissionKind kind, bool value)
         {
-            RowVersion++;
+            return new Permission(kind, value);
         }
 
-        /*************************************************************************
-         * Navigation properties
-         *************************************************************************/
-
-        public virtual event PropertyChangedEventHandler PropertyChanged;
-
-        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
+        /// <inheritdoc/>
+        public void OnSavingChanges()
         {
-            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+            RowVersion++;
         }
 
+        partial void Init();
     }
 }
-

+ 44 - 56
Jellyfin.Data/Entities/Preference.cs

@@ -1,63 +1,33 @@
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations.Schema;
+using Jellyfin.Data.Enums;
 
 namespace Jellyfin.Data.Entities
 {
-    public partial class Preference
+    /// <summary>
+    /// An entity representing a preference attached to a user or group.
+    /// </summary>
+    public class Preference : ISavingChanges
     {
-        partial void Init();
-
-        /// <summary>
-        /// Default constructor. Protected due to required properties, but present because EF needs it.
-        /// </summary>
-        protected Preference()
-        {
-            Init();
-        }
-
         /// <summary>
-        /// Replaces default constructor, since it's protected. Caller assumes responsibility for setting all required values before saving.
+        /// Initializes a new instance of the <see cref="Preference"/> class.
+        /// Public constructor with required data.
         /// </summary>
-        public static Preference CreatePreferenceUnsafe()
+        /// <param name="kind">The preference kind.</param>
+        /// <param name="value">The value.</param>
+        public Preference(PreferenceKind kind, string value)
         {
-            return new Preference();
-        }
-
-        /// <summary>
-        /// Public constructor with required data
-        /// </summary>
-        /// <param name="kind"></param>
-        /// <param name="value"></param>
-        /// <param name="_user0"></param>
-        /// <param name="_group1"></param>
-        public Preference(Enums.PreferenceKind kind, string value, User _user0, Group _group1)
-        {
-            this.Kind = kind;
-
-            if (string.IsNullOrEmpty(value)) throw new ArgumentNullException(nameof(value));
-            this.Value = value;
-
-            if (_user0 == null) throw new ArgumentNullException(nameof(_user0));
-            _user0.Preferences.Add(this);
-
-            if (_group1 == null) throw new ArgumentNullException(nameof(_group1));
-            _group1.Preferences.Add(this);
-
-
-            Init();
+            Kind = kind;
+            Value = value ?? throw new ArgumentNullException(nameof(value));
         }
 
         /// <summary>
-        /// Static create function (for use in LINQ queries, etc.)
+        /// Initializes a new instance of the <see cref="Preference"/> class.
+        /// Default constructor. Protected due to required properties, but present because EF needs it.
         /// </summary>
-        /// <param name="kind"></param>
-        /// <param name="value"></param>
-        /// <param name="_user0"></param>
-        /// <param name="_group1"></param>
-        public static Preference Create(Enums.PreferenceKind kind, string value, User _user0, Group _group1)
+        protected Preference()
         {
-            return new Preference(kind, value, _user0, _group1);
         }
 
         /*************************************************************************
@@ -65,43 +35,61 @@ namespace Jellyfin.Data.Entities
          *************************************************************************/
 
         /// <summary>
-        /// Identity, Indexed, Required
+        /// Gets or sets the id of this preference.
         /// </summary>
+        /// <remarks>
+        /// Identity, Indexed, Required.
+        /// </remarks>
         [Key]
         [Required]
         [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
         public int Id { get; protected set; }
 
         /// <summary>
-        /// Required
+        /// Gets or sets the type of this preference.
         /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
         [Required]
-        public Enums.PreferenceKind Kind { get; set; }
+        public PreferenceKind Kind { get; protected set; }
 
         /// <summary>
-        /// Required, Max length = 65535
+        /// Gets or sets the value of this preference.
         /// </summary>
+        /// <remarks>
+        /// Required, Max length = 65535.
+        /// </remarks>
         [Required]
         [MaxLength(65535)]
         [StringLength(65535)]
         public string Value { get; set; }
 
         /// <summary>
-        /// Required, ConcurrenyToken
+        /// Gets or sets the row version.
         /// </summary>
+        /// <remarks>
+        /// Required, ConcurrencyToken.
+        /// </remarks>
         [ConcurrencyCheck]
         [Required]
         public uint RowVersion { get; set; }
 
+        /// <summary>
+        /// Static create function (for use in LINQ queries, etc.)
+        /// </summary>
+        /// <param name="kind">The preference kind.</param>
+        /// <param name="value">The value.</param>
+        /// <returns>The new instance.</returns>
+        public static Preference Create(PreferenceKind kind, string value)
+        {
+            return new Preference(kind, value);
+        }
+
+        /// <inheritdoc/>
         public void OnSavingChanges()
         {
             RowVersion++;
         }
-
-        /*************************************************************************
-         * Navigation properties
-         *************************************************************************/
-
     }
 }
-

+ 0 - 6
Jellyfin.Data/Entities/ProviderMapping.cs

@@ -43,12 +43,6 @@ namespace Jellyfin.Data.Entities
             if (string.IsNullOrEmpty(providerdata)) throw new ArgumentNullException(nameof(providerdata));
             this.ProviderData = providerdata;
 
-            if (_user0 == null) throw new ArgumentNullException(nameof(_user0));
-            _user0.ProviderMappings.Add(this);
-
-            if (_group1 == null) throw new ArgumentNullException(nameof(_group1));
-            _group1.ProviderMappings.Add(this);
-
 
             Init();
         }

+ 6 - 14
Jellyfin.Data/Entities/Series.cs

@@ -19,14 +19,6 @@ namespace Jellyfin.Data.Entities
             Init();
         }
 
-        /// <summary>
-        /// Replaces default constructor, since it's protected. Caller assumes responsibility for setting all required values before saving.
-        /// </summary>
-        public static Series CreateSeriesUnsafe()
-        {
-            return new Series();
-        }
-
         /// <summary>
         /// Public constructor with required data
         /// </summary>
@@ -57,27 +49,27 @@ namespace Jellyfin.Data.Entities
         /// <summary>
         /// Backing field for AirsDayOfWeek
         /// </summary>
-        protected Enums.Weekday? _AirsDayOfWeek;
+        protected DayOfWeek? _AirsDayOfWeek;
         /// <summary>
         /// When provided in a partial class, allows value of AirsDayOfWeek to be changed before setting.
         /// </summary>
-        partial void SetAirsDayOfWeek(Enums.Weekday? oldValue, ref Enums.Weekday? newValue);
+        partial void SetAirsDayOfWeek(DayOfWeek? oldValue, ref DayOfWeek? newValue);
         /// <summary>
         /// When provided in a partial class, allows value of AirsDayOfWeek to be changed before returning.
         /// </summary>
-        partial void GetAirsDayOfWeek(ref Enums.Weekday? result);
+        partial void GetAirsDayOfWeek(ref DayOfWeek? result);
 
-        public Enums.Weekday? AirsDayOfWeek
+        public DayOfWeek? AirsDayOfWeek
         {
             get
             {
-                Enums.Weekday? value = _AirsDayOfWeek;
+                DayOfWeek? value = _AirsDayOfWeek;
                 GetAirsDayOfWeek(ref value);
                 return (_AirsDayOfWeek = value);
             }
             set
             {
-                Enums.Weekday? oldValue = _AirsDayOfWeek;
+                DayOfWeek? oldValue = _AirsDayOfWeek;
                 SetAirsDayOfWeek(oldValue, ref value);
                 if (oldValue != value)
                 {

+ 386 - 114
Jellyfin.Data/Entities/User.cs

@@ -2,234 +2,506 @@ using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations.Schema;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json.Serialization;
+using Jellyfin.Data.Enums;
 
 namespace Jellyfin.Data.Entities
 {
-    public partial class User
+    /// <summary>
+    /// An entity representing a user.
+    /// </summary>
+    public partial class User : IHasPermissions, ISavingChanges
     {
-        partial void Init();
+        /// <summary>
+        /// The values being delimited here are Guids, so commas work as they do not appear in Guids.
+        /// </summary>
+        private const char Delimiter = ',';
 
         /// <summary>
-        /// Default constructor. Protected due to required properties, but present because EF needs it.
+        /// Initializes a new instance of the <see cref="User"/> class.
+        /// Public constructor with required data.
         /// </summary>
-        protected User()
+        /// <param name="username">The username for the new user.</param>
+        /// <param name="authenticationProviderId">The Id of the user's authentication provider.</param>
+        /// <param name="passwordResetProviderId">The Id of the user's password reset provider.</param>
+        public User(string username, string authenticationProviderId, string passwordResetProviderId)
         {
-            Groups = new HashSet<Group>();
+            if (string.IsNullOrEmpty(username))
+            {
+                throw new ArgumentNullException(nameof(username));
+            }
+
+            if (string.IsNullOrEmpty(authenticationProviderId))
+            {
+                throw new ArgumentNullException(nameof(authenticationProviderId));
+            }
+
+            if (string.IsNullOrEmpty(passwordResetProviderId))
+            {
+                throw new ArgumentNullException(nameof(passwordResetProviderId));
+            }
+
+            Username = username;
+            AuthenticationProviderId = authenticationProviderId;
+            PasswordResetProviderId = passwordResetProviderId;
+
+            AccessSchedules = new HashSet<AccessSchedule>();
+            // Groups = new HashSet<Group>();
             Permissions = new HashSet<Permission>();
-            ProviderMappings = new HashSet<ProviderMapping>();
             Preferences = new HashSet<Preference>();
-
+            // ProviderMappings = new HashSet<ProviderMapping>();
+
+            // Set default values
+            Id = Guid.NewGuid();
+            InvalidLoginAttemptCount = 0;
+            EnableUserPreferenceAccess = true;
+            MustUpdatePassword = false;
+            DisplayMissingEpisodes = false;
+            DisplayCollectionsView = false;
+            HidePlayedInLatest = true;
+            RememberAudioSelections = true;
+            RememberSubtitleSelections = true;
+            EnableNextEpisodeAutoPlay = true;
+            EnableAutoLogin = false;
+            PlayDefaultAudioTrack = true;
+            SubtitleMode = SubtitlePlaybackMode.Default;
+            SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
+
+            AddDefaultPermissions();
+            AddDefaultPreferences();
             Init();
         }
 
         /// <summary>
-        /// Replaces default constructor, since it's protected. Caller assumes responsibility for setting all required values before saving.
-        /// </summary>
-        public static User CreateUserUnsafe()
-        {
-            return new User();
-        }
-
-        /// <summary>
-        /// Public constructor with required data
+        /// Initializes a new instance of the <see cref="User"/> class.
+        /// Default constructor. Protected due to required properties, but present because EF needs it.
         /// </summary>
-        /// <param name="username"></param>
-        /// <param name="mustupdatepassword"></param>
-        /// <param name="audiolanguagepreference"></param>
-        /// <param name="authenticationproviderid"></param>
-        /// <param name="invalidloginattemptcount"></param>
-        /// <param name="subtitlemode"></param>
-        /// <param name="playdefaultaudiotrack"></param>
-        public User(string username, bool mustupdatepassword, string audiolanguagepreference, string authenticationproviderid, int invalidloginattemptcount, string subtitlemode, bool playdefaultaudiotrack)
+        protected User()
         {
-            if (string.IsNullOrEmpty(username)) throw new ArgumentNullException(nameof(username));
-            this.Username = username;
-
-            this.MustUpdatePassword = mustupdatepassword;
-
-            if (string.IsNullOrEmpty(audiolanguagepreference)) throw new ArgumentNullException(nameof(audiolanguagepreference));
-            this.AudioLanguagePreference = audiolanguagepreference;
-
-            if (string.IsNullOrEmpty(authenticationproviderid)) throw new ArgumentNullException(nameof(authenticationproviderid));
-            this.AuthenticationProviderId = authenticationproviderid;
-
-            this.InvalidLoginAttemptCount = invalidloginattemptcount;
-
-            if (string.IsNullOrEmpty(subtitlemode)) throw new ArgumentNullException(nameof(subtitlemode));
-            this.SubtitleMode = subtitlemode;
-
-            this.PlayDefaultAudioTrack = playdefaultaudiotrack;
-
-            this.Groups = new HashSet<Group>();
-            this.Permissions = new HashSet<Permission>();
-            this.ProviderMappings = new HashSet<ProviderMapping>();
-            this.Preferences = new HashSet<Preference>();
-
             Init();
         }
 
-        /// <summary>
-        /// Static create function (for use in LINQ queries, etc.)
-        /// </summary>
-        /// <param name="username"></param>
-        /// <param name="mustupdatepassword"></param>
-        /// <param name="audiolanguagepreference"></param>
-        /// <param name="authenticationproviderid"></param>
-        /// <param name="invalidloginattemptcount"></param>
-        /// <param name="subtitlemode"></param>
-        /// <param name="playdefaultaudiotrack"></param>
-        public static User Create(string username, bool mustupdatepassword, string audiolanguagepreference, string authenticationproviderid, int invalidloginattemptcount, string subtitlemode, bool playdefaultaudiotrack)
-        {
-            return new User(username, mustupdatepassword, audiolanguagepreference, authenticationproviderid, invalidloginattemptcount, subtitlemode, playdefaultaudiotrack);
-        }
-
         /*************************************************************************
          * Properties
          *************************************************************************/
 
         /// <summary>
-        /// Identity, Indexed, Required
+        /// Gets or sets the Id of the user.
         /// </summary>
+        /// <remarks>
+        /// Identity, Indexed, Required.
+        /// </remarks>
         [Key]
         [Required]
-        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
-        public int Id { get; protected set; }
+        [JsonIgnore]
+        public Guid Id { get; set; }
 
         /// <summary>
-        /// Required, Max length = 255
+        /// Gets or sets the user's name.
         /// </summary>
+        /// <remarks>
+        /// Required, Max length = 255.
+        /// </remarks>
         [Required]
         [MaxLength(255)]
         [StringLength(255)]
         public string Username { get; set; }
 
         /// <summary>
-        /// Max length = 65535
+        /// Gets or sets the user's password, or <c>null</c> if none is set.
         /// </summary>
+        /// <remarks>
+        /// Max length = 65535.
+        /// </remarks>
         [MaxLength(65535)]
         [StringLength(65535)]
         public string Password { get; set; }
 
         /// <summary>
-        /// Required
+        /// Gets or sets the user's easy password, or <c>null</c> if none is set.
+        /// </summary>
+        /// <remarks>
+        /// Max length = 65535.
+        /// </remarks>
+        [MaxLength(65535)]
+        [StringLength(65535)]
+        public string EasyPassword { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the user must update their password.
         /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
         [Required]
         public bool MustUpdatePassword { get; set; }
 
         /// <summary>
-        /// Required, Max length = 255
+        /// Gets or sets the audio language preference.
         /// </summary>
-        [Required]
+        /// <remarks>
+        /// Max length = 255.
+        /// </remarks>
         [MaxLength(255)]
         [StringLength(255)]
         public string AudioLanguagePreference { get; set; }
 
         /// <summary>
-        /// Required, Max length = 255
+        /// Gets or sets the authentication provider id.
         /// </summary>
+        /// <remarks>
+        /// Required, Max length = 255.
+        /// </remarks>
         [Required]
         [MaxLength(255)]
         [StringLength(255)]
         public string AuthenticationProviderId { get; set; }
 
         /// <summary>
-        /// Max length = 65535
+        /// Gets or sets the password reset provider id.
         /// </summary>
-        [MaxLength(65535)]
-        [StringLength(65535)]
-        public string GroupedFolders { get; set; }
+        /// <remarks>
+        /// Required, Max length = 255.
+        /// </remarks>
+        [Required]
+        [MaxLength(255)]
+        [StringLength(255)]
+        public string PasswordResetProviderId { get; set; }
 
         /// <summary>
-        /// Required
+        /// Gets or sets the invalid login attempt count.
         /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
         [Required]
         public int InvalidLoginAttemptCount { get; set; }
 
         /// <summary>
-        /// Max length = 65535
+        /// Gets or sets the last activity date.
         /// </summary>
-        [MaxLength(65535)]
-        [StringLength(65535)]
-        public string LatestItemExcludes { get; set; }
-
-        public int? LoginAttemptsBeforeLockout { get; set; }
+        public DateTime? LastActivityDate { get; set; }
 
         /// <summary>
-        /// Max length = 65535
+        /// Gets or sets the last login date.
         /// </summary>
-        [MaxLength(65535)]
-        [StringLength(65535)]
-        public string MyMediaExcludes { get; set; }
+        public DateTime? LastLoginDate { get; set; }
 
         /// <summary>
-        /// Max length = 65535
+        /// Gets or sets the number of login attempts the user can make before they are locked out.
         /// </summary>
-        [MaxLength(65535)]
-        [StringLength(65535)]
-        public string OrderedViews { get; set; }
+        public int? LoginAttemptsBeforeLockout { get; set; }
 
         /// <summary>
-        /// Required, Max length = 255
+        /// Gets or sets the subtitle mode.
         /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
         [Required]
-        [MaxLength(255)]
-        [StringLength(255)]
-        public string SubtitleMode { get; set; }
+        public SubtitlePlaybackMode SubtitleMode { get; set; }
 
         /// <summary>
-        /// Required
+        /// Gets or sets a value indicating whether the default audio track should be played.
         /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
         [Required]
         public bool PlayDefaultAudioTrack { get; set; }
 
         /// <summary>
-        /// Max length = 255
+        /// Gets or sets the subtitle language preference.
         /// </summary>
+        /// <remarks>
+        /// Max length = 255.
+        /// </remarks>
         [MaxLength(255)]
         [StringLength(255)]
-        public string SubtitleLanguagePrefernce { get; set; }
+        public string SubtitleLanguagePreference { get; set; }
 
-        public bool? DisplayMissingEpisodes { get; set; }
+        /// <summary>
+        /// Gets or sets a value indicating whether missing episodes should be displayed.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool DisplayMissingEpisodes { get; set; }
 
-        public bool? DisplayCollectionsView { get; set; }
+        /// <summary>
+        /// Gets or sets a value indicating whether to display the collections view.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool DisplayCollectionsView { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the user has a local password.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool EnableLocalPassword { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the server should hide played content in "Latest".
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool HidePlayedInLatest { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to remember audio selections on played content.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool RememberAudioSelections { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to remember subtitle selections on played content.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool RememberSubtitleSelections { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to enable auto-play for the next episode.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool EnableNextEpisodeAutoPlay { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the user should auto-login.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool EnableAutoLogin { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the user can change their preferences.
+        /// </summary>
+        /// <remarks>
+        /// Required.
+        /// </remarks>
+        [Required]
+        public bool EnableUserPreferenceAccess { get; set; }
 
-        public bool? HidePlayedInLatest { get; set; }
+        /// <summary>
+        /// Gets or sets the maximum parental age rating.
+        /// </summary>
+        public int? MaxParentalAgeRating { get; set; }
 
-        public bool? RememberAudioSelections { get; set; }
+        /// <summary>
+        /// Gets or sets the remote client bitrate limit.
+        /// </summary>
+        public int? RemoteClientBitrateLimit { get; set; }
 
-        public bool? RememberSubtitleSelections { get; set; }
+        /// <summary>
+        /// Gets or sets the internal id.
+        /// This is a temporary stopgap for until the library db is migrated.
+        /// This corresponds to the value of the index of this user in the library db.
+        /// </summary>
+        [Required]
+        public long InternalId { get; set; }
 
-        public bool? EnableNextEpisodeAutoPlay { get; set; }
+        /// <summary>
+        /// Gets or sets the user's profile image. Can be <c>null</c>.
+        /// </summary>
+        // [ForeignKey("UserId")]
+        public virtual ImageInfo ProfileImage { get; set; }
 
-        public bool? EnableUserPreferenceAccess { get; set; }
+        [Required]
+        public SyncPlayAccess SyncPlayAccess { get; set; }
 
         /// <summary>
-        /// Required, ConcurrenyToken
+        /// Gets or sets the row version.
         /// </summary>
+        /// <remarks>
+        /// Required, Concurrency Token.
+        /// </remarks>
         [ConcurrencyCheck]
         [Required]
         public uint RowVersion { get; set; }
 
-        public void OnSavingChanges()
-        {
-            RowVersion++;
-        }
-
         /*************************************************************************
          * Navigation properties
          *************************************************************************/
-        [ForeignKey("Group_Groups_Id")]
+
+        /// <summary>
+        /// Gets or sets the list of access schedules this user has.
+        /// </summary>
+        public virtual ICollection<AccessSchedule> AccessSchedules { get; protected set; }
+
+        /*
+        /// <summary>
+        /// Gets or sets the list of groups this user is a member of.
+        /// </summary>
+        [ForeignKey("Group_Groups_Guid")]
         public virtual ICollection<Group> Groups { get; protected set; }
+        */
 
-        [ForeignKey("Permission_Permissions_Id")]
+        /// <summary>
+        /// Gets or sets the list of permissions this user has.
+        /// </summary>
+        [ForeignKey("Permission_Permissions_Guid")]
         public virtual ICollection<Permission> Permissions { get; protected set; }
 
+        /*
+        /// <summary>
+        /// Gets or sets the list of provider mappings this user has.
+        /// </summary>
         [ForeignKey("ProviderMapping_ProviderMappings_Id")]
         public virtual ICollection<ProviderMapping> ProviderMappings { get; protected set; }
+        */
 
-        [ForeignKey("Preference_Preferences_Id")]
+        /// <summary>
+        /// Gets or sets the list of preferences this user has.
+        /// </summary>
+        [ForeignKey("Preference_Preferences_Guid")]
         public virtual ICollection<Preference> Preferences { get; protected set; }
 
+        /// <summary>
+        /// Static create function (for use in LINQ queries, etc.)
+        /// </summary>
+        /// <param name="username">The username for the created user.</param>
+        /// <param name="authenticationProviderId">The Id of the user's authentication provider.</param>
+        /// <param name="passwordResetProviderId">The Id of the user's password reset provider.</param>
+        /// <returns>The created instance.</returns>
+        public static User Create(string username, string authenticationProviderId, string passwordResetProviderId)
+        {
+            return new User(username, authenticationProviderId, passwordResetProviderId);
+        }
+
+        /// <inheritdoc/>
+        public void OnSavingChanges()
+        {
+            RowVersion++;
+        }
+
+        /// <summary>
+        /// Checks whether the user has the specified permission.
+        /// </summary>
+        /// <param name="kind">The permission kind.</param>
+        /// <returns><c>True</c> if the user has the specified permission.</returns>
+        public bool HasPermission(PermissionKind kind)
+        {
+            return Permissions.First(p => p.Kind == kind).Value;
+        }
+
+        /// <summary>
+        /// Sets the given permission kind to the provided value.
+        /// </summary>
+        /// <param name="kind">The permission kind.</param>
+        /// <param name="value">The value to set.</param>
+        public void SetPermission(PermissionKind kind, bool value)
+        {
+            Permissions.First(p => p.Kind == kind).Value = value;
+        }
+
+        /// <summary>
+        /// Gets the user's preferences for the given preference kind.
+        /// </summary>
+        /// <param name="preference">The preference kind.</param>
+        /// <returns>A string array containing the user's preferences.</returns>
+        public string[] GetPreference(PreferenceKind preference)
+        {
+            var val = Preferences.First(p => p.Kind == preference).Value;
+
+            return Equals(val, string.Empty) ? Array.Empty<string>() : val.Split(Delimiter);
+        }
+
+        /// <summary>
+        /// Sets the specified preference to the given value.
+        /// </summary>
+        /// <param name="preference">The preference kind.</param>
+        /// <param name="values">The values.</param>
+        public void SetPreference(PreferenceKind preference, string[] values)
+        {
+            Preferences.First(p => p.Kind == preference).Value
+                = string.Join(Delimiter.ToString(CultureInfo.InvariantCulture), values);
+        }
+
+        /// <summary>
+        /// Checks whether this user is currently allowed to use the server.
+        /// </summary>
+        /// <returns><c>True</c> if the current time is within an access schedule, or there are no access schedules.</returns>
+        public bool IsParentalScheduleAllowed()
+        {
+            return AccessSchedules.Count == 0
+                   || AccessSchedules.Any(i => IsParentalScheduleAllowed(i, DateTime.UtcNow));
+        }
+
+        /// <summary>
+        /// Checks whether the provided folder is in this user's grouped folders.
+        /// </summary>
+        /// <param name="id">The Guid of the folder.</param>
+        /// <returns><c>True</c> if the folder is in the user's grouped folders.</returns>
+        public bool IsFolderGrouped(Guid id)
+        {
+            return GetPreference(PreferenceKind.GroupedFolders).Any(i => new Guid(i) == id);
+        }
+
+        private static bool IsParentalScheduleAllowed(AccessSchedule schedule, DateTime date)
+        {
+            var localTime = date.ToLocalTime();
+            var hour = localTime.TimeOfDay.TotalHours;
+
+            return DayOfWeekHelper.GetDaysOfWeek(schedule.DayOfWeek).Contains(localTime.DayOfWeek)
+                   && hour >= schedule.StartHour
+                   && hour <= schedule.EndHour;
+        }
+
+        // TODO: make these user configurable?
+        private void AddDefaultPermissions()
+        {
+            Permissions.Add(new Permission(PermissionKind.IsAdministrator, false));
+            Permissions.Add(new Permission(PermissionKind.IsDisabled, false));
+            Permissions.Add(new Permission(PermissionKind.IsHidden, true));
+            Permissions.Add(new Permission(PermissionKind.EnableAllChannels, true));
+            Permissions.Add(new Permission(PermissionKind.EnableAllDevices, true));
+            Permissions.Add(new Permission(PermissionKind.EnableAllFolders, true));
+            Permissions.Add(new Permission(PermissionKind.EnableContentDeletion, false));
+            Permissions.Add(new Permission(PermissionKind.EnableContentDownloading, true));
+            Permissions.Add(new Permission(PermissionKind.EnableMediaConversion, true));
+            Permissions.Add(new Permission(PermissionKind.EnableMediaPlayback, true));
+            Permissions.Add(new Permission(PermissionKind.EnablePlaybackRemuxing, true));
+            Permissions.Add(new Permission(PermissionKind.EnablePublicSharing, true));
+            Permissions.Add(new Permission(PermissionKind.EnableRemoteAccess, true));
+            Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
+            Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
+            Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
+            Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true));
+            Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
+            Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
+            Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
+            Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false));
+        }
+
+        private void AddDefaultPreferences()
+        {
+            foreach (var val in Enum.GetValues(typeof(PreferenceKind)).Cast<PreferenceKind>())
+            {
+                Preferences.Add(new Preference(val, string.Empty));
+            }
+        }
+
+        partial void Init();
     }
 }
-

+ 1 - 1
MediaBrowser.Model/Configuration/DynamicDayOfWeek.cs → Jellyfin.Data/Enums/DynamicDayOfWeek.cs

@@ -1,6 +1,6 @@
 #pragma warning disable CS1591
 
-namespace MediaBrowser.Model.Configuration
+namespace Jellyfin.Data.Enums
 {
     public enum DynamicDayOfWeek
     {

+ 107 - 20
Jellyfin.Data/Enums/PermissionKind.cs

@@ -1,26 +1,113 @@
 namespace Jellyfin.Data.Enums
 {
+    /// <summary>
+    /// The types of user permissions.
+    /// </summary>
     public enum PermissionKind
     {
-        IsAdministrator,
-        IsHidden,
-        IsDisabled,
-        BlockUnrateditems,
-        EnbleSharedDeviceControl,
-        EnableRemoteAccess,
-        EnableLiveTvManagement,
-        EnableLiveTvAccess,
-        EnableMediaPlayback,
-        EnableAudioPlaybackTranscoding,
-        EnableVideoPlaybackTranscoding,
-        EnableContentDeletion,
-        EnableContentDownloading,
-        EnableSyncTranscoding,
-        EnableMediaConversion,
-        EnableAllDevices,
-        EnableAllChannels,
-        EnableAllFolders,
-        EnablePublicSharing,
-        AccessSchedules
+        /// <summary>
+        /// Whether the user is an administrator.
+        /// </summary>
+        IsAdministrator = 0,
+
+        /// <summary>
+        /// Whether the user is hidden.
+        /// </summary>
+        IsHidden = 1,
+
+        /// <summary>
+        /// Whether the user is disabled.
+        /// </summary>
+        IsDisabled = 2,
+
+        /// <summary>
+        /// Whether the user can control shared devices.
+        /// </summary>
+        EnableSharedDeviceControl = 3,
+
+        /// <summary>
+        /// Whether the user can access the server remotely.
+        /// </summary>
+        EnableRemoteAccess = 4,
+
+        /// <summary>
+        /// Whether the user can manage live tv.
+        /// </summary>
+        EnableLiveTvManagement = 5,
+
+        /// <summary>
+        /// Whether the user can access live tv.
+        /// </summary>
+        EnableLiveTvAccess = 6,
+
+        /// <summary>
+        /// Whether the user can play media.
+        /// </summary>
+        EnableMediaPlayback = 7,
+
+        /// <summary>
+        /// Whether the server should transcode audio for the user if requested.
+        /// </summary>
+        EnableAudioPlaybackTranscoding = 8,
+
+        /// <summary>
+        /// Whether the server should transcode video for the user if requested.
+        /// </summary>
+        EnableVideoPlaybackTranscoding = 9,
+
+        /// <summary>
+        /// Whether the user can delete content.
+        /// </summary>
+        EnableContentDeletion = 10,
+
+        /// <summary>
+        /// Whether the user can download content.
+        /// </summary>
+        EnableContentDownloading = 11,
+
+        /// <summary>
+        /// Whether to enable sync transcoding for the user.
+        /// </summary>
+        EnableSyncTranscoding = 12,
+
+        /// <summary>
+        /// Whether the user can do media conversion.
+        /// </summary>
+        EnableMediaConversion = 13,
+
+        /// <summary>
+        /// Whether the user has access to all devices.
+        /// </summary>
+        EnableAllDevices = 14,
+
+        /// <summary>
+        /// Whether the user has access to all channels.
+        /// </summary>
+        EnableAllChannels = 15,
+
+        /// <summary>
+        /// Whether the user has access to all folders.
+        /// </summary>
+        EnableAllFolders = 16,
+
+        /// <summary>
+        /// Whether to enable public sharing for the user.
+        /// </summary>
+        EnablePublicSharing = 17,
+
+        /// <summary>
+        /// Whether the user can remotely control other users.
+        /// </summary>
+        EnableRemoteControlOfOtherUsers = 18,
+
+        /// <summary>
+        /// Whether the user is permitted to do playback remuxing.
+        /// </summary>
+        EnablePlaybackRemuxing = 19,
+
+        /// <summary>
+        /// Whether the server should force transcoding on remote connections for the user.
+        /// </summary>
+        ForceRemoteSourceTranscoding = 20
     }
 }

+ 62 - 7
Jellyfin.Data/Enums/PreferenceKind.cs

@@ -1,13 +1,68 @@
 namespace Jellyfin.Data.Enums
 {
+    /// <summary>
+    /// The types of user preferences.
+    /// </summary>
     public enum PreferenceKind
     {
-        MaxParentalRating,
-        BlockedTags,
-        RemoteClientBitrateLimit,
-        EnabledDevices,
-        EnabledChannels,
-        EnabledFolders,
-        EnableContentDeletionFromFolders
+        /// <summary>
+        /// A list of blocked tags.
+        /// </summary>
+        BlockedTags = 0,
+
+        /// <summary>
+        /// A list of blocked channels.
+        /// </summary>
+        BlockedChannels = 1,
+
+        /// <summary>
+        /// A list of blocked media folders.
+        /// </summary>
+        BlockedMediaFolders = 2,
+
+        /// <summary>
+        /// A list of enabled devices.
+        /// </summary>
+        EnabledDevices = 3,
+
+        /// <summary>
+        /// A list of enabled channels
+        /// </summary>
+        EnabledChannels = 4,
+
+        /// <summary>
+        /// A list of enabled folders.
+        /// </summary>
+        EnabledFolders = 5,
+
+        /// <summary>
+        /// A list of folders to allow content deletion from.
+        /// </summary>
+        EnableContentDeletionFromFolders = 6,
+
+        /// <summary>
+        /// A list of latest items to exclude.
+        /// </summary>
+        LatestItemExcludes = 7,
+
+        /// <summary>
+        /// A list of media to exclude.
+        /// </summary>
+        MyMediaExcludes = 8,
+
+        /// <summary>
+        /// A list of grouped folders.
+        /// </summary>
+        GroupedFolders = 9,
+
+        /// <summary>
+        /// A list of unrated items to block.
+        /// </summary>
+        BlockUnratedItems = 10,
+
+        /// <summary>
+        /// A list of ordered views.
+        /// </summary>
+        OrderedViews = 11
     }
 }

+ 2 - 2
MediaBrowser.Model/Configuration/SubtitlePlaybackMode.cs → Jellyfin.Data/Enums/SubtitlePlaybackMode.cs

@@ -1,6 +1,6 @@
-#pragma warning disable CS1591
+#pragma warning disable CS1591
 
-namespace MediaBrowser.Model.Configuration
+namespace Jellyfin.Data.Enums
 {
     public enum SubtitlePlaybackMode
     {

+ 4 - 4
MediaBrowser.Model/Configuration/SyncplayAccess.cs → Jellyfin.Data/Enums/SyncPlayAccess.cs

@@ -1,4 +1,4 @@
-namespace MediaBrowser.Model.Configuration
+namespace Jellyfin.Data.Enums
 {
     /// <summary>
     /// Enum SyncPlayAccess.
@@ -8,16 +8,16 @@ namespace MediaBrowser.Model.Configuration
         /// <summary>
         /// User can create groups and join them.
         /// </summary>
-        CreateAndJoinGroups,
+        CreateAndJoinGroups = 0,
 
         /// <summary>
         /// User can only join already existing groups.
         /// </summary>
-        JoinGroups,
+        JoinGroups = 1,
 
         /// <summary>
         /// SyncPlay is disabled for the user.
         /// </summary>
-        None
+        None = 2
     }
 }

+ 1 - 1
MediaBrowser.Model/Configuration/UnratedItem.cs → Jellyfin.Data/Enums/UnratedItem.cs

@@ -1,6 +1,6 @@
 #pragma warning disable CS1591
 
-namespace MediaBrowser.Model.Configuration
+namespace Jellyfin.Data.Enums
 {
     public enum UnratedItem
     {

+ 0 - 13
Jellyfin.Data/Enums/Weekday.cs

@@ -1,13 +0,0 @@
-namespace Jellyfin.Data.Enums
-{
-    public enum Weekday
-    {
-        Sunday,
-        Monday,
-        Tuesday,
-        Wednesday,
-        Thursday,
-        Friday,
-        Saturday
-    }
-}

+ 31 - 0
Jellyfin.Data/IHasPermissions.cs

@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+
+namespace Jellyfin.Data
+{
+    /// <summary>
+    /// An abstraction representing an entity that has permissions.
+    /// </summary>
+    public interface IHasPermissions
+    {
+        /// <summary>
+        /// Gets a collection containing this entity's permissions.
+        /// </summary>
+        ICollection<Permission> Permissions { get; }
+
+        /// <summary>
+        /// Checks whether this entity has the specified permission kind.
+        /// </summary>
+        /// <param name="kind">The kind of permission.</param>
+        /// <returns><c>true</c> if this entity has the specified permission, <c>false</c> otherwise.</returns>
+        bool HasPermission(PermissionKind kind);
+
+        /// <summary>
+        /// Sets the specified permission to the provided value.
+        /// </summary>
+        /// <param name="kind">The kind of permission.</param>
+        /// <param name="value">The value to set.</param>
+        void SetPermission(PermissionKind kind, bool value);
+    }
+}

+ 1 - 0
Jellyfin.Data/Jellyfin.Data.csproj

@@ -21,6 +21,7 @@
   <ItemGroup>
     <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="3.1.5" />
     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.5" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="3.1.5" />
   </ItemGroup>
 
 </Project>

Някои файлове не бяха показани, защото твърде много файлове са промени