Selaa lähdekoodia

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

crobibero 5 vuotta sitten
vanhempi
sitoutus
cbcf3bfaff
100 muutettua tiedostoa jossa 6236 lisäystä ja 2090 poistoa
  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 13
      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. 0 1
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  18. 1 0
      Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
  19. 2 1
      Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs
  20. 0 77
      Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs
  21. 5 23
      Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs
  22. 0 1
      Emby.Server.Implementations/HttpServer/ResponseFilter.cs
  23. 36 29
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  24. 78 37
      Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
  25. 1 1
      Emby.Server.Implementations/HttpServer/Security/SessionContext.cs
  26. 6 4
      Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
  27. 7 2
      Emby.Server.Implementations/Library/LibraryManager.cs
  28. 23 24
      Emby.Server.Implementations/Library/MediaSourceManager.cs
  29. 1 1
      Emby.Server.Implementations/Library/MediaStreamSelector.cs
  30. 7 11
      Emby.Server.Implementations/Library/MusicManager.cs
  31. 3 0
      Emby.Server.Implementations/Library/SearchEngine.cs
  32. 2 0
      Emby.Server.Implementations/Library/UserDataManager.cs
  33. 0 1107
      Emby.Server.Implementations/Library/UserManager.cs
  34. 20 10
      Emby.Server.Implementations/Library/UserViewManager.cs
  35. 12 13
      Emby.Server.Implementations/LiveTv/LiveTvManager.cs
  36. 2 1
      Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs
  37. 3 0
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  38. 52 30
      Emby.Server.Implementations/Session/SessionManager.cs
  39. 1 0
      Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
  40. 1 0
      Emby.Server.Implementations/Sorting/DatePlayedComparer.cs
  41. 1 0
      Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs
  42. 1 0
      Emby.Server.Implementations/Sorting/IsPlayedComparer.cs
  43. 1 0
      Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs
  44. 1 0
      Emby.Server.Implementations/Sorting/PlayCountComparer.cs
  45. 27 52
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  46. 9 5
      Emby.Server.Implementations/TV/TVSeriesManager.cs
  47. 100 0
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  48. 13 11
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  49. 42 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  50. 11 0
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
  51. 18 4
      Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
  52. 42 0
      Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs
  53. 11 0
      Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs
  54. 44 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
  55. 11 0
      Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
  56. 24 2
      Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
  57. 38 0
      Jellyfin.Api/Constants/InternalClaimTypes.cs
  58. 15 0
      Jellyfin.Api/Constants/Policies.cs
  59. 2 3
      Jellyfin.Api/Controllers/ActivityLogController.cs
  60. 57 0
      Jellyfin.Api/Controllers/BrandingController.cs
  61. 12 14
      Jellyfin.Api/Controllers/ConfigurationController.cs
  62. 275 0
      Jellyfin.Api/Controllers/DashboardController.cs
  63. 10 11
      Jellyfin.Api/Controllers/DevicesController.cs
  64. 76 0
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  65. 221 0
      Jellyfin.Api/Controllers/FilterController.cs
  66. 230 0
      Jellyfin.Api/Controllers/ImageByNameController.cs
  67. 364 0
      Jellyfin.Api/Controllers/ItemLookupController.cs
  68. 90 0
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  69. 238 187
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  70. 341 0
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  71. 76 0
      Jellyfin.Api/Controllers/LocalizationController.cs
  72. 31 20
      Jellyfin.Api/Controllers/NotificationsController.cs
  73. 19 19
      Jellyfin.Api/Controllers/PackageController.cs
  74. 199 0
      Jellyfin.Api/Controllers/PlaylistsController.cs
  75. 200 0
      Jellyfin.Api/Controllers/PluginsController.cs
  76. 266 0
      Jellyfin.Api/Controllers/RemoteImageController.cs
  77. 2 1
      Jellyfin.Api/Controllers/SearchController.cs
  78. 475 0
      Jellyfin.Api/Controllers/SessionController.cs
  79. 25 19
      Jellyfin.Api/Controllers/StartupController.cs
  80. 347 0
      Jellyfin.Api/Controllers/SubtitleController.cs
  81. 87 0
      Jellyfin.Api/Controllers/SuggestionsController.cs
  82. 222 0
      Jellyfin.Api/Controllers/SystemController.cs
  83. 380 0
      Jellyfin.Api/Controllers/TvShowsController.cs
  84. 552 0
      Jellyfin.Api/Controllers/UserController.cs
  85. 3 4
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  86. 202 0
      Jellyfin.Api/Controllers/VideosController.cs
  87. 162 0
      Jellyfin.Api/Extensions/DtoExtensions.cs
  88. 75 0
      Jellyfin.Api/Helpers/ClaimHelpers.cs
  89. 65 0
      Jellyfin.Api/Helpers/RequestHelpers.cs
  90. 0 2
      Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
  91. 30 8
      Jellyfin.Api/Models/ConfigurationPageInfo.cs
  92. 0 2
      Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs
  93. 0 2
      Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs
  94. 0 2
      Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs
  95. 30 0
      Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
  96. 40 0
      Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs
  97. 18 0
      Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs
  98. 3 5
      Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
  99. 2 4
      Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
  100. 23 0
      Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs

+ 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 - 13
Emby.Server.Implementations/ApplicationHost.cs

@@ -97,7 +97,6 @@ using MediaBrowser.Providers.Chapters;
 using MediaBrowser.Providers.Manager;
 using MediaBrowser.Providers.Plugins.TheTvdb;
 using MediaBrowser.Providers.Subtitles;
-using MediaBrowser.WebDashboard.Api;
 using MediaBrowser.XbmcMetadata.Providers;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
@@ -562,11 +561,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 +655,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 +742,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>();
@@ -1045,9 +1036,6 @@ namespace Emby.Server.Implementations
             // Include composable parts in the Api assembly
             yield return typeof(ApiEntryPoint).Assembly;
 
-            // Include composable parts in the Dashboard assembly
-            yield return typeof(DashboardService).Assembly;
-
             // Include composable parts in the Model assembly
             yield return typeof(SystemInfo).Assembly;
 

+ 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;

+ 0 - 1
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -13,7 +13,6 @@
     <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
     <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
     <ProjectReference Include="..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" />
-    <ProjectReference Include="..\MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj" />
     <ProjectReference Include="..\MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj" />
     <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" />
     <ProjectReference Include="..\MediaBrowser.Api\MediaBrowser.Api.csproj" />

+ 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();

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

@@ -0,0 +1,100 @@
+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;
+        }
+    }
+}

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

@@ -1,8 +1,10 @@
+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 +40,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 +53,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";
     }
 }

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

@@ -1,7 +1,5 @@
-#nullable enable
-#pragma warning disable CA1801
-
 using System;
+using System.Diagnostics.CodeAnalysis;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Data.Entities;
@@ -42,6 +40,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
         [HttpGet("Entries")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "hasUserId", Justification = "Imported from ServiceStack")]
         public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries(
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,

+ 57 - 0
Jellyfin.Api/Controllers/BrandingController.cs

@@ -0,0 +1,57 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Branding;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Branding controller.
+    /// </summary>
+    public class BrandingController : BaseJellyfinApiController
+    {
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="BrandingController"/> class.
+        /// </summary>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public BrandingController(IServerConfigurationManager serverConfigurationManager)
+        {
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <summary>
+        /// Gets branding configuration.
+        /// </summary>
+        /// <response code="200">Branding configuration returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns>
+        [HttpGet("Configuration")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<BrandingOptions> GetBrandingOptions()
+        {
+            return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+        }
+
+        /// <summary>
+        /// Gets branding css.
+        /// </summary>
+        /// <response code="200">Branding css returned.</response>
+        /// <response code="204">No branding css configured.</response>
+        /// <returns>
+        /// An <see cref="OkResult"/> containing the branding css if exist,
+        /// or a <see cref="NoContentResult"/> if the css is not configured.
+        /// </returns>
+        [HttpGet("Css")]
+        [HttpGet("Css.css")]
+        [Produces("text/css")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult<string> GetBrandingCss()
+        {
+            var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
+            return options.CustomCss ?? string.Empty;
+        }
+    }
+}

+ 12 - 14
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>
@@ -70,7 +68,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="key">Configuration key.</param>
         /// <response code="200">Configuration returned.</response>
         /// <returns>Configuration.</returns>
-        [HttpGet("Configuration/{Key}")]
+        [HttpGet("Configuration/{key}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<object> GetNamedConfiguration([FromRoute] string key)
         {
@@ -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}")]
+        [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();
         }
     }
 }

+ 275 - 0
Jellyfin.Api/Controllers/DashboardController.cs

@@ -0,0 +1,275 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using Jellyfin.Api.Models;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Extensions;
+using MediaBrowser.Controller.Plugins;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Plugins;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The dashboard controller.
+    /// </summary>
+    public class DashboardController : BaseJellyfinApiController
+    {
+        private readonly ILogger<DashboardController> _logger;
+        private readonly IServerApplicationHost _appHost;
+        private readonly IConfiguration _appConfig;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+        private readonly IResourceFileManager _resourceFileManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DashboardController"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
+        /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="appConfig">Instance of <see cref="IConfiguration"/> interface.</param>
+        /// <param name="resourceFileManager">Instance of <see cref="IResourceFileManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+        public DashboardController(
+            ILogger<DashboardController> logger,
+            IServerApplicationHost appHost,
+            IConfiguration appConfig,
+            IResourceFileManager resourceFileManager,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _logger = logger;
+            _appHost = appHost;
+            _appConfig = appConfig;
+            _resourceFileManager = resourceFileManager;
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <summary>
+        /// Gets the path of the directory containing the static web interface content, or null if the server is not
+        /// hosting the web client.
+        /// </summary>
+        private string? WebClientUiPath => GetWebClientUiPath(_appConfig, _serverConfigurationManager);
+
+        /// <summary>
+        /// Gets the configuration pages.
+        /// </summary>
+        /// <param name="enableInMainMenu">Whether to enable in the main menu.</param>
+        /// <param name="pageType">The <see cref="ConfigurationPageInfo"/>.</param>
+        /// <response code="200">ConfigurationPages returned.</response>
+        /// <response code="404">Server still loading.</response>
+        /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns>
+        [HttpGet("/web/ConfigurationPages")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<IEnumerable<ConfigurationPageInfo?>> GetConfigurationPages(
+            [FromQuery] bool? enableInMainMenu,
+            [FromQuery] ConfigurationPageType? pageType)
+        {
+            const string unavailableMessage = "The server is still loading. Please try again momentarily.";
+
+            var pages = _appHost.GetExports<IPluginConfigurationPage>().ToList();
+
+            if (pages == null)
+            {
+                return NotFound(unavailableMessage);
+            }
+
+            // Don't allow a failing plugin to fail them all
+            var configPages = pages.Select(p =>
+                {
+                    try
+                    {
+                        return new ConfigurationPageInfo(p);
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name);
+                        return null;
+                    }
+                })
+                .Where(i => i != null)
+                .ToList();
+
+            configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages));
+
+            if (pageType.HasValue)
+            {
+                configPages = configPages.Where(p => p!.ConfigurationPageType == pageType).ToList();
+            }
+
+            if (enableInMainMenu.HasValue)
+            {
+                configPages = configPages.Where(p => p!.EnableInMainMenu == enableInMainMenu.Value).ToList();
+            }
+
+            return configPages;
+        }
+
+        /// <summary>
+        /// Gets a dashboard configuration page.
+        /// </summary>
+        /// <param name="name">The name of the page.</param>
+        /// <response code="200">ConfigurationPage returned.</response>
+        /// <response code="404">Plugin configuration page not found.</response>
+        /// <returns>The configuration page.</returns>
+        [HttpGet("/web/ConfigurationPage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult GetDashboardConfigurationPage([FromQuery] string name)
+        {
+            IPlugin? plugin = null;
+            Stream? stream = null;
+
+            var isJs = false;
+            var isTemplate = false;
+
+            var page = _appHost.GetExports<IPluginConfigurationPage>().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
+            if (page != null)
+            {
+                plugin = page.Plugin;
+                stream = page.GetHtmlStream();
+            }
+
+            if (plugin == null)
+            {
+                var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase));
+                if (altPage != null)
+                {
+                    plugin = altPage.Item2;
+                    stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath);
+
+                    isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase);
+                    isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal);
+                }
+            }
+
+            if (plugin != null && stream != null)
+            {
+                if (isJs)
+                {
+                    return File(stream, MimeTypes.GetMimeType("page.js"));
+                }
+
+                if (isTemplate)
+                {
+                    return File(stream, MimeTypes.GetMimeType("page.html"));
+                }
+
+                return File(stream, MimeTypes.GetMimeType("page.html"));
+            }
+
+            return NotFound();
+        }
+
+        /// <summary>
+        /// Gets the robots.txt.
+        /// </summary>
+        /// <response code="200">Robots.txt returned.</response>
+        /// <returns>The robots.txt.</returns>
+        [HttpGet("/robots.txt")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ApiExplorerSettings(IgnoreApi = true)]
+        public ActionResult GetRobotsTxt()
+        {
+            return GetWebClientResource("robots.txt", string.Empty);
+        }
+
+        /// <summary>
+        /// Gets a resource from the web client.
+        /// </summary>
+        /// <param name="resourceName">The resource name.</param>
+        /// <param name="v">The v.</param>
+        /// <response code="200">Web client returned.</response>
+        /// <response code="404">Server does not host a web client.</response>
+        /// <returns>The resource.</returns>
+        [HttpGet("/web/{*resourceName}")]
+        [ApiExplorerSettings(IgnoreApi = true)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "v", Justification = "Imported from ServiceStack")]
+        public ActionResult GetWebClientResource(
+            [FromRoute] string resourceName,
+            [FromQuery] string? v)
+        {
+            if (!_appConfig.HostWebClient() || WebClientUiPath == null)
+            {
+                return NotFound("Server does not host a web client.");
+            }
+
+            var path = resourceName;
+            var basePath = WebClientUiPath;
+
+            // Bounce them to the startup wizard if it hasn't been completed yet
+            if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted
+                && !Request.Path.Value.Contains("wizard", StringComparison.OrdinalIgnoreCase)
+                && Request.Path.Value.Contains("index", StringComparison.OrdinalIgnoreCase))
+            {
+                return Redirect("index.html?start=wizard#!/wizardstart.html");
+            }
+
+            var stream = new FileStream(_resourceFileManager.GetResourcePath(basePath, path), FileMode.Open, FileAccess.Read);
+            return File(stream, MimeTypes.GetMimeType(path));
+        }
+
+        /// <summary>
+        /// Gets the favicon.
+        /// </summary>
+        /// <response code="200">Favicon.ico returned.</response>
+        /// <returns>The favicon.</returns>
+        [HttpGet("/favicon.ico")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ApiExplorerSettings(IgnoreApi = true)]
+        public ActionResult GetFavIcon()
+        {
+            return GetWebClientResource("favicon.ico", string.Empty);
+        }
+
+        /// <summary>
+        /// Gets the path of the directory containing the static web interface content.
+        /// </summary>
+        /// <param name="appConfig">The app configuration.</param>
+        /// <param name="serverConfigManager">The server configuration manager.</param>
+        /// <returns>The directory path, or null if the server is not hosting the web client.</returns>
+        public static string? GetWebClientUiPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager)
+        {
+            if (!appConfig.HostWebClient())
+            {
+                return null;
+            }
+
+            if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath))
+            {
+                return serverConfigManager.Configuration.DashboardSourcePath;
+            }
+
+            return serverConfigManager.ApplicationPaths.WebPath;
+        }
+
+        private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin)
+        {
+            return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1));
+        }
+
+        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin)
+        {
+            if (!(plugin is IHasWebPages hasWebPages))
+            {
+                return new List<Tuple<PluginPageInfo, IPlugin>>();
+            }
+
+            return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin));
+        }
+
+        private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
+        {
+            return _appHost.Plugins.SelectMany(GetPluginPages);
+        }
+    }
+}

+ 10 - 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,19 @@ 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)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult DeleteDevice([FromQuery, BindRequired] string id)
         {
             var existingDevice = _deviceManager.GetDevice(id);
@@ -150,7 +149,7 @@ namespace Jellyfin.Api.Controllers
                 _sessionManager.Logout(session);
             }
 
-            return Ok();
+            return NoContent();
         }
     }
 }

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

@@ -0,0 +1,76 @@
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Entities;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Display Preferences Controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class DisplayPreferencesController : BaseJellyfinApiController
+    {
+        private readonly IDisplayPreferencesRepository _displayPreferencesRepository;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
+        /// </summary>
+        /// <param name="displayPreferencesRepository">Instance of <see cref="IDisplayPreferencesRepository"/> interface.</param>
+        public DisplayPreferencesController(IDisplayPreferencesRepository displayPreferencesRepository)
+        {
+            _displayPreferencesRepository = displayPreferencesRepository;
+        }
+
+        /// <summary>
+        /// Get Display Preferences.
+        /// </summary>
+        /// <param name="displayPreferencesId">Display preferences id.</param>
+        /// <param name="userId">User id.</param>
+        /// <param name="client">Client.</param>
+        /// <response code="200">Display preferences retrieved.</response>
+        /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns>
+        [HttpGet("{displayPreferencesId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<DisplayPreferences> GetDisplayPreferences(
+            [FromRoute] string displayPreferencesId,
+            [FromQuery] [Required] string userId,
+            [FromQuery] [Required] string client)
+        {
+            return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client);
+        }
+
+        /// <summary>
+        /// Update Display Preferences.
+        /// </summary>
+        /// <param name="displayPreferencesId">Display preferences id.</param>
+        /// <param name="userId">User Id.</param>
+        /// <param name="client">Client.</param>
+        /// <param name="displayPreferences">New Display Preferences object.</param>
+        /// <response code="204">Display preferences updated.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+        [HttpPost("{displayPreferencesId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
+        public ActionResult UpdateDisplayPreferences(
+            [FromRoute] string displayPreferencesId,
+            [FromQuery, BindRequired] string userId,
+            [FromQuery, BindRequired] string client,
+            [FromBody, BindRequired] DisplayPreferences displayPreferences)
+        {
+            _displayPreferencesRepository.SaveDisplayPreferences(
+                displayPreferences,
+                userId,
+                client,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+    }
+}

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

@@ -0,0 +1,221 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Jellyfin.Api.Constants;
+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(Policy = Policies.DefaultAuthorization)]
+    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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "mediaTypes", Justification = "Imported from ServiceStack")]
+        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;
+        }
+    }
+}

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

@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using Jellyfin.Api.Constants;
+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(Policy = Policies.DefaultAuthorization)]
+        [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(Policy = Policies.DefaultAuthorization)]
+        [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(Policy = Policies.DefaultAuthorization)]
+        [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;
+        }
+    }
+}

+ 364 - 0
Jellyfin.Api/Controllers/ItemLookupController.cs

@@ -0,0 +1,364 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+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.IO;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Item lookup controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class ItemLookupController : BaseJellyfinApiController
+    {
+        private readonly IProviderManager _providerManager;
+        private readonly IServerApplicationPaths _appPaths;
+        private readonly IFileSystem _fileSystem;
+        private readonly ILibraryManager _libraryManager;
+        private readonly ILogger<ItemLookupController> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ItemLookupController"/> class.
+        /// </summary>
+        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param>
+        public ItemLookupController(
+            IProviderManager providerManager,
+            IServerConfigurationManager serverConfigurationManager,
+            IFileSystem fileSystem,
+            ILibraryManager libraryManager,
+            ILogger<ItemLookupController> logger)
+        {
+            _providerManager = providerManager;
+            _appPaths = serverConfigurationManager.ApplicationPaths;
+            _fileSystem = fileSystem;
+            _libraryManager = libraryManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        /// Get the item's external id info.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">External id info retrieved.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>List of external id info.</returns>
+        [HttpGet("/Items/{itemId}/ExternalIdInfos")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return Ok(_providerManager.GetExternalIdInfos(item));
+        }
+
+        /// <summary>
+        /// Get movie remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Movie remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/Movie")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<MovieInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get trailer remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Trailer remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/Trailer")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<TrailerInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get music video remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Music video remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/MusicVideo")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<MusicVideoInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get series remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Series remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/Series")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<SeriesInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get box set remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Box set remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/BoxSet")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<BoxSetInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get music artist remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Music artist remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/MusicArtist")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<ArtistInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get music album remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Music album remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/MusicAlbum")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<AlbumInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get person remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Person remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/Person")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<PersonLookupInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Get book remote search.
+        /// </summary>
+        /// <param name="query">Remote search query.</param>
+        /// <response code="200">Book remote search executed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/Book")]
+        public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<BookInfo> query)
+        {
+            var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None)
+                .ConfigureAwait(false);
+            return Ok(results);
+        }
+
+        /// <summary>
+        /// Gets a remote image.
+        /// </summary>
+        /// <param name="imageUrl">The image url.</param>
+        /// <param name="providerName">The provider name.</param>
+        /// <response code="200">Remote image retrieved.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="FileStreamResult"/> containing the images file stream.
+        /// </returns>
+        [HttpGet("/Items/RemoteSearch/Image")]
+        public async Task<ActionResult> GetRemoteSearchImage(
+            [FromQuery, Required] string imageUrl,
+            [FromQuery, Required] string providerName)
+        {
+            var urlHash = imageUrl.GetMD5();
+            var pointerCachePath = GetFullCachePath(urlHash.ToString());
+
+            try
+            {
+                var contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+                if (System.IO.File.Exists(contentPath))
+                {
+                    await using var fileStreamExisting = System.IO.File.OpenRead(pointerCachePath);
+                    return new FileStreamResult(fileStreamExisting, MediaTypeNames.Application.Octet);
+                }
+            }
+            catch (FileNotFoundException)
+            {
+                // Means the file isn't cached yet
+            }
+            catch (IOException)
+            {
+                // Means the file isn't cached yet
+            }
+
+            await DownloadImage(providerName, imageUrl, urlHash, pointerCachePath).ConfigureAwait(false);
+
+            // Read the pointer file again
+            await using var fileStream = System.IO.File.OpenRead(pointerCachePath);
+            return new FileStreamResult(fileStream, MediaTypeNames.Application.Octet);
+        }
+
+        /// <summary>
+        /// Applies search criteria to an item and refreshes metadata.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="searchResult">The remote search result.</param>
+        /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param>
+        /// <response code="204">Item metadata refreshed.</response>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
+        /// The task result contains an <see cref="NoContentResult"/>.
+        /// </returns>
+        [HttpPost("/Items/RemoteSearch/Apply/{id}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public async Task<ActionResult> ApplySearchCriteria(
+            [FromRoute] Guid itemId,
+            [FromBody, BindRequired] RemoteSearchResult searchResult,
+            [FromQuery] bool replaceAllImages = true)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            _logger.LogInformation(
+                "Setting provider id's to item {0}-{1}: {2}",
+                item.Id,
+                item.Name,
+                JsonSerializer.Serialize(searchResult.ProviderIds));
+
+            // Since the refresh process won't erase provider Ids, we need to set this explicitly now.
+            item.ProviderIds = searchResult.ProviderIds;
+            await _providerManager.RefreshFullItem(
+                item,
+                new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+                {
+                    MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                    ReplaceAllMetadata = true,
+                    ReplaceAllImages = replaceAllImages,
+                    SearchResult = searchResult
+                }, CancellationToken.None).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Downloads the image.
+        /// </summary>
+        /// <param name="providerName">Name of the provider.</param>
+        /// <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 providerName, string url, Guid urlHash, string pointerCachePath)
+        {
+            var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).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).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets the full cache path.
+        /// </summary>
+        /// <param name="filename">The filename.</param>
+        /// <returns>System.String.</returns>
+        private string GetFullCachePath(string filename)
+            => Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename);
+    }
+}

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

@@ -0,0 +1,90 @@
+using System;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using Jellyfin.Api.Constants;
+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(Policy = Policies.DefaultAuthorization)]
+    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="itemId">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="204">Item metadata refresh queued.</response>
+        /// <response code="404">Item to refresh not found.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+        [HttpPost("{itemId}/Refresh")]
+        [Description("Refreshes metadata for an item.")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "recursive", Justification = "Imported from ServiceStack")]
+        public ActionResult Post(
+            [FromRoute] Guid itemId,
+            [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(itemId);
+            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 NoContent();
+        }
+    }
+}

+ 238 - 187
MediaBrowser.Api/ItemUpdateService.cs → Jellyfin.Api/Controllers/ItemUpdateController.cs

@@ -1,215 +1,99 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
+using Jellyfin.Api.Constants;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
 
-namespace MediaBrowser.Api
+namespace Jellyfin.Api.Controllers
 {
-    [Route("/Items/{ItemId}", "POST", Summary = "Updates an item")]
-    public class UpdateItem : BaseItemDto, IReturnVoid
-    {
-        [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string ItemId { get; set; }
-    }
-
-    [Route("/Items/{ItemId}/MetadataEditor", "GET", Summary = "Gets metadata editor info for an item")]
-    public class GetMetadataEditorInfo : IReturn<MetadataEditorInfo>
-    {
-        [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string ItemId { get; set; }
-    }
-
-    [Route("/Items/{ItemId}/ContentType", "POST", Summary = "Updates an item's content type")]
-    public class UpdateItemContentType : IReturnVoid
-    {
-        [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public Guid ItemId { get; set; }
-
-        [ApiMember(Name = "ContentType", Description = "The content type of the item", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string ContentType { get; set; }
-    }
-
-    [Authenticated(Roles = "admin")]
-    public class ItemUpdateService : BaseApiService
+    /// <summary>
+    /// Item update controller.
+    /// </summary>
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public class ItemUpdateController : BaseJellyfinApiController
     {
         private readonly ILibraryManager _libraryManager;
         private readonly IProviderManager _providerManager;
         private readonly ILocalizationManager _localizationManager;
         private readonly IFileSystem _fileSystem;
-
-        public ItemUpdateService(
-            ILogger<ItemUpdateService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ItemUpdateController"/> class.
+        /// </summary>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+        /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public ItemUpdateController(
             IFileSystem fileSystem,
             ILibraryManager libraryManager,
             IProviderManager providerManager,
-            ILocalizationManager localizationManager)
-            : base(logger, serverConfigurationManager, httpResultFactory)
+            ILocalizationManager localizationManager,
+            IServerConfigurationManager serverConfigurationManager)
         {
             _libraryManager = libraryManager;
             _providerManager = providerManager;
             _localizationManager = localizationManager;
             _fileSystem = fileSystem;
+            _serverConfigurationManager = serverConfigurationManager;
         }
 
-        public object Get(GetMetadataEditorInfo request)
+        /// <summary>
+        /// Updates an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="request">The new item properties.</param>
+        /// <response code="204">Item updated.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+        [HttpPost("/Items/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult UpdateItem([FromRoute] Guid itemId, [FromBody, BindRequired] BaseItemDto request)
         {
-            var item = _libraryManager.GetItemById(request.ItemId);
-
-            var info = new MetadataEditorInfo
-            {
-                ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(),
-                ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
-                Countries = _localizationManager.GetCountries().ToArray(),
-                Cultures = _localizationManager.GetCultures().ToArray()
-            };
-
-            if (!item.IsVirtualItem && !(item is ICollectionFolder) && !(item is UserView) && !(item is AggregateFolder) && !(item is LiveTvChannel) && !(item is IItemByName) &&
-                item.SourceType == SourceType.Library)
-            {
-                var inheritedContentType = _libraryManager.GetInheritedContentType(item);
-                var configuredContentType = _libraryManager.GetConfiguredContentType(item);
-
-                if (string.IsNullOrWhiteSpace(inheritedContentType) || !string.IsNullOrWhiteSpace(configuredContentType))
-                {
-                    info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
-                    info.ContentType = configuredContentType;
-
-                    if (string.IsNullOrWhiteSpace(inheritedContentType) || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
-                    {
-                        info.ContentTypeOptions = info.ContentTypeOptions
-                            .Where(i => string.IsNullOrWhiteSpace(i.Value) || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
-                            .ToArray();
-                    }
-                }
-            }
-
-            return ToOptimizedResult(info);
-        }
-
-        public void Post(UpdateItemContentType request)
-        {
-            var item = _libraryManager.GetItemById(request.ItemId);
-            var path = item.ContainingFolderPath;
-
-            var types = ServerConfigurationManager.Configuration.ContentTypes
-                .Where(i => !string.IsNullOrWhiteSpace(i.Name))
-                .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
-                .ToList();
-
-            if (!string.IsNullOrWhiteSpace(request.ContentType))
-            {
-                types.Add(new NameValuePair
-                {
-                    Name = path,
-                    Value = request.ContentType
-                });
-            }
-
-            ServerConfigurationManager.Configuration.ContentTypes = types.ToArray();
-            ServerConfigurationManager.SaveConfiguration();
-        }
-
-        private List<NameValuePair> GetContentTypeOptions(bool isForItem)
-        {
-            var list = new List<NameValuePair>();
-
-            if (isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "Inherit",
-                    Value = ""
-                });
-            }
-
-            list.Add(new NameValuePair
-            {
-                Name = "Movies",
-                Value = "movies"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Music",
-                Value = "music"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Shows",
-                Value = "tvshows"
-            });
-
-            if (!isForItem)
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
             {
-                list.Add(new NameValuePair
-                {
-                    Name = "Books",
-                    Value = "books"
-                });
+                return NotFound();
             }
 
-            list.Add(new NameValuePair
-            {
-                Name = "HomeVideos",
-                Value = "homevideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "MusicVideos",
-                Value = "musicvideos"
-            });
-            list.Add(new NameValuePair
-            {
-                Name = "Photos",
-                Value = "photos"
-            });
-
-            if (!isForItem)
-            {
-                list.Add(new NameValuePair
-                {
-                    Name = "MixedContent",
-                    Value = ""
-                });
-            }
-
-            foreach (var val in list)
-            {
-                val.Name = _localizationManager.GetLocalizedString(val.Name);
-            }
-
-            return list;
-        }
-
-        public void Post(UpdateItem request)
-        {
-            var item = _libraryManager.GetItemById(request.ItemId);
-
             var newLockData = request.LockData ?? false;
             var isLockedChanged = item.IsLocked != newLockData;
 
             var series = item as Series;
-            var displayOrderChanged = series != null && !string.Equals(series.DisplayOrder ?? string.Empty, request.DisplayOrder ?? string.Empty, StringComparison.OrdinalIgnoreCase);
+            var displayOrderChanged = series != null && !string.Equals(
+                series.DisplayOrder ?? string.Empty,
+                request.DisplayOrder ?? string.Empty,
+                StringComparison.OrdinalIgnoreCase);
 
             // Do this first so that metadata savers can pull the updates from the database.
             if (request.People != null)
             {
-                _libraryManager.UpdatePeople(item, request.People.Select(x => new PersonInfo { Name = x.Name, Role = x.Role, Type = x.Type }).ToList());
+                _libraryManager.UpdatePeople(
+                    item,
+                    request.People.Select(x => new PersonInfo
+                    {
+                        Name = x.Name,
+                        Role = x.Role,
+                        Type = x.Type
+                    }).ToList());
             }
 
             UpdateItem(request, item);
@@ -232,7 +116,7 @@ namespace MediaBrowser.Api
             if (displayOrderChanged)
             {
                 _providerManager.QueueRefresh(
-                    series.Id,
+                    series!.Id,
                     new MetadataRefreshOptions(new DirectoryService(_fileSystem))
                     {
                         MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
@@ -241,11 +125,101 @@ namespace MediaBrowser.Api
                     },
                     RefreshPriority.High);
             }
+
+            return NoContent();
         }
 
-        private DateTime NormalizeDateTime(DateTime val)
+        /// <summary>
+        /// Gets metadata editor info for an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <response code="200">Item metadata editor returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+        [HttpGet("/Items/{itemId}/MetadataEditor")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute] Guid itemId)
         {
-            return DateTime.SpecifyKind(val, DateTimeKind.Utc);
+            var item = _libraryManager.GetItemById(itemId);
+
+            var info = new MetadataEditorInfo
+            {
+                ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(),
+                ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
+                Countries = _localizationManager.GetCountries().ToArray(),
+                Cultures = _localizationManager.GetCultures().ToArray()
+            };
+
+            if (!item.IsVirtualItem
+                && !(item is ICollectionFolder)
+                && !(item is UserView)
+                && !(item is AggregateFolder)
+                && !(item is LiveTvChannel)
+                && !(item is IItemByName)
+                && item.SourceType == SourceType.Library)
+            {
+                var inheritedContentType = _libraryManager.GetInheritedContentType(item);
+                var configuredContentType = _libraryManager.GetConfiguredContentType(item);
+
+                if (string.IsNullOrWhiteSpace(inheritedContentType) ||
+                    !string.IsNullOrWhiteSpace(configuredContentType))
+                {
+                    info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
+                    info.ContentType = configuredContentType;
+
+                    if (string.IsNullOrWhiteSpace(inheritedContentType)
+                        || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
+                    {
+                        info.ContentTypeOptions = info.ContentTypeOptions
+                            .Where(i => string.IsNullOrWhiteSpace(i.Value)
+                                        || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))
+                            .ToArray();
+                    }
+                }
+            }
+
+            return info;
+        }
+
+        /// <summary>
+        /// Updates an item's content type.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="contentType">The content type of the item.</param>
+        /// <response code="204">Item content type updated.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+        [HttpPost("/Items/{itemId}/ContentType")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string contentType)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var path = item.ContainingFolderPath;
+
+            var types = _serverConfigurationManager.Configuration.ContentTypes
+                .Where(i => !string.IsNullOrWhiteSpace(i.Name))
+                .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
+                .ToList();
+
+            if (!string.IsNullOrWhiteSpace(contentType))
+            {
+                types.Add(new NameValuePair
+                {
+                    Name = path,
+                    Value = contentType
+                });
+            }
+
+            _serverConfigurationManager.Configuration.ContentTypes = types.ToArray();
+            _serverConfigurationManager.SaveConfiguration();
+            return NoContent();
         }
 
         private void UpdateItem(BaseItemDto request, BaseItem item)
@@ -361,24 +335,25 @@ namespace MediaBrowser.Api
                 }
             }
 
-            if (item is Audio song)
-            {
-                song.Album = request.Album;
-            }
-
-            if (item is MusicVideo musicVideo)
+            switch (item)
             {
-                musicVideo.Album = request.Album;
-            }
+                case Audio song:
+                    song.Album = request.Album;
+                    break;
+                case MusicVideo musicVideo:
+                    musicVideo.Album = request.Album;
+                    break;
+                case Series series:
+                {
+                    series.Status = GetSeriesStatus(request);
 
-            if (item is Series series)
-            {
-                series.Status = GetSeriesStatus(request);
+                    if (request.AirDays != null)
+                    {
+                        series.AirDays = request.AirDays;
+                        series.AirTime = request.AirTime;
+                    }
 
-                if (request.AirDays != null)
-                {
-                    series.AirDays = request.AirDays;
-                    series.AirTime = request.AirTime;
+                    break;
                 }
             }
         }
@@ -392,5 +367,81 @@ namespace MediaBrowser.Api
 
             return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true);
         }
+
+        private DateTime NormalizeDateTime(DateTime val)
+        {
+            return DateTime.SpecifyKind(val, DateTimeKind.Utc);
+        }
+
+        private List<NameValuePair> GetContentTypeOptions(bool isForItem)
+        {
+            var list = new List<NameValuePair>();
+
+            if (isForItem)
+            {
+                list.Add(new NameValuePair
+                {
+                    Name = "Inherit",
+                    Value = string.Empty
+                });
+            }
+
+            list.Add(new NameValuePair
+            {
+                Name = "Movies",
+                Value = "movies"
+            });
+            list.Add(new NameValuePair
+            {
+                Name = "Music",
+                Value = "music"
+            });
+            list.Add(new NameValuePair
+            {
+                Name = "Shows",
+                Value = "tvshows"
+            });
+
+            if (!isForItem)
+            {
+                list.Add(new NameValuePair
+                {
+                    Name = "Books",
+                    Value = "books"
+                });
+            }
+
+            list.Add(new NameValuePair
+            {
+                Name = "HomeVideos",
+                Value = "homevideos"
+            });
+            list.Add(new NameValuePair
+            {
+                Name = "MusicVideos",
+                Value = "musicvideos"
+            });
+            list.Add(new NameValuePair
+            {
+                Name = "Photos",
+                Value = "photos"
+            });
+
+            if (!isForItem)
+            {
+                list.Add(new NameValuePair
+                {
+                    Name = "MixedContent",
+                    Value = string.Empty
+                });
+            }
+
+            foreach (var val in list)
+            {
+                val.Name = _localizationManager.GetLocalizedString(val.Name);
+            }
+
+            return list;
+        }
     }
 }

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

@@ -0,0 +1,341 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+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)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+        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());
+        }
+    }
+}

+ 31 - 20
Jellyfin.Api/Controllers/NotificationsController.cs

@@ -1,11 +1,10 @@
-#nullable enable
-#pragma warning disable CA1801
-
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 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;
@@ -43,8 +42,12 @@ namespace Jellyfin.Api.Controllers
         /// <param name="limit">An optional limit on the number of notifications returned.</param>
         /// <response code="200">Notifications returned.</response>
         /// <returns>An <see cref="OkResult"/> containing a list of notifications.</returns>
-        [HttpGet("{UserID}")]
+        [HttpGet("{userId}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isRead", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
         public ActionResult<NotificationResultDto> GetNotifications(
             [FromRoute] string userId,
             [FromQuery] bool? isRead,
@@ -60,8 +63,9 @@ namespace Jellyfin.Api.Controllers
         /// <param name="userId">The user's ID.</param>
         /// <response code="200">Summary of user's notifications returned.</response>
         /// <returns>An <cref see="OkResult"/> containing a summary of the users notifications.</returns>
-        [HttpGet("{UserID}/Summary")]
+        [HttpGet("{userId}/Summary")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
         public ActionResult<NotificationsSummaryDto> GetNotificationsSummary(
             [FromRoute] string userId)
         {
@@ -99,10 +103,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 +119,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 +136,17 @@ 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>
-        [HttpPost("{UserID}/Read")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        /// <response code="204">Notifications set as read.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
+        [HttpPost("{userId}/Read")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "ids", Justification = "Imported from ServiceStack")]
         public ActionResult SetRead(
             [FromRoute] string userId,
             [FromQuery] string ids)
         {
-            return Ok();
+            return NoContent();
         }
 
         /// <summary>
@@ -145,15 +154,17 @@ 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>
-        [HttpPost("{UserID}/Unread")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        /// <response code="204">Notifications set as unread.</response>
+        /// <returns>A <cref see="NoContentResult"/>.</returns>
+        [HttpPost("{userId}/Unread")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "ids", Justification = "Imported from ServiceStack")]
         public ActionResult SetUnread(
             [FromRoute] string userId,
             [FromQuery] string ids)
         {
-            return Ok();
+            return NoContent();
         }
     }
 }

+ 19 - 19
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;
@@ -37,9 +35,10 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="name">The name of the package.</param>
         /// <param name="assemblyGuid">The GUID of the associated assembly.</param>
+        /// <response code="200">Package retrieved.</response>
         /// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
-        [HttpGet("/{Name}")]
-        [ProducesResponseType(typeof(PackageInfo), StatusCodes.Status200OK)]
+        [HttpGet("/{name}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult<PackageInfo>> GetPackageInfo(
             [FromRoute] [Required] string name,
             [FromQuery] string? assemblyGuid)
@@ -56,9 +55,10 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets available packages.
         /// </summary>
+        /// <response code="200">Available packages returned.</response>
         /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
         [HttpGet]
-        [ProducesResponseType(typeof(PackageInfo[]), StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<IEnumerable<PackageInfo>> GetPackages()
         {
             IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
@@ -72,11 +72,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>
-        [HttpPost("/Installed/{Name}")]
-        [ProducesResponseType(StatusCodes.Status200OK)]
+        /// <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.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [Authorize(Policy = Policies.RequiresElevation)]
         public async Task<ActionResult> InstallPackage(
@@ -98,23 +98,23 @@ 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>
-        [HttpDelete("/Installing/{id}")]
+        /// <param name="packageId">Installation Id.</param>
+        /// <response code="204">Installation cancelled.</response>
+        /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
+        [HttpDelete("/Installing/{packageId}")]
         [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public IActionResult CancelPackageInstallation(
-            [FromRoute] [Required] string id)
+            [FromRoute] [Required] Guid packageId)
         {
-            _installationManager.CancelInstallation(new Guid(id));
-
-            return Ok();
+            _installationManager.CancelInstallation(packageId);
+            return NoContent();
         }
     }
 }

+ 199 - 0
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -0,0 +1,199 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.PlaylistDtos;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Playlists;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Playlists controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class PlaylistsController : BaseJellyfinApiController
+    {
+        private readonly IPlaylistManager _playlistManager;
+        private readonly IDtoService _dtoService;
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlaylistsController"/> class.
+        /// </summary>
+        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+        /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        public PlaylistsController(
+            IDtoService dtoService,
+            IPlaylistManager playlistManager,
+            IUserManager userManager,
+            ILibraryManager libraryManager)
+        {
+            _dtoService = dtoService;
+            _playlistManager = playlistManager;
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+        }
+
+        /// <summary>
+        /// Creates a new playlist.
+        /// </summary>
+        /// <param name="createPlaylistRequest">The create playlist payload.</param>
+        /// <returns>
+        /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist.
+        /// The task result contains an <see cref="OkResult"/> indicating success.
+        /// </returns>
+        [HttpPost]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
+            [FromBody, BindRequired] CreatePlaylistDto createPlaylistRequest)
+        {
+            Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids);
+            var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
+            {
+                Name = createPlaylistRequest.Name,
+                ItemIdList = idGuidArray,
+                UserId = createPlaylistRequest.UserId,
+                MediaType = createPlaylistRequest.MediaType
+            }).ConfigureAwait(false);
+
+            return result;
+        }
+
+        /// <summary>
+        /// Adds items to a playlist.
+        /// </summary>
+        /// <param name="playlistId">The playlist id.</param>
+        /// <param name="ids">Item id, comma delimited.</param>
+        /// <param name="userId">The userId.</param>
+        /// <response code="204">Items added to playlist.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+        [HttpPost("{playlistId}/Items")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult AddToPlaylist(
+            [FromRoute] string playlistId,
+            [FromQuery] string ids,
+            [FromQuery] Guid userId)
+        {
+            _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Moves a playlist item.
+        /// </summary>
+        /// <param name="playlistId">The playlist id.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="newIndex">The new index.</param>
+        /// <response code="204">Item moved to new index.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+        [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult MoveItem(
+            [FromRoute] string playlistId,
+            [FromRoute] string itemId,
+            [FromRoute] int newIndex)
+        {
+            _playlistManager.MoveItem(playlistId, itemId, newIndex);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Removes items from a playlist.
+        /// </summary>
+        /// <param name="playlistId">The playlist id.</param>
+        /// <param name="entryIds">The item ids, comma delimited.</param>
+        /// <response code="204">Items removed.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success.</returns>
+        [HttpDelete("{playlistId}/Items")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult RemoveFromPlaylist([FromRoute] string playlistId, [FromQuery] string entryIds)
+        {
+            _playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true));
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Gets the original items of a playlist.
+        /// </summary>
+        /// <param name="playlistId">The playlist id.</param>
+        /// <param name="userId">User id.</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="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="enableImages">Optional. Include image information in output.</param>
+        /// <param name="enableUserData">Optional. Include user data.</param>
+        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+        /// <response code="200">Original playlist returned.</response>
+        /// <response code="404">Playlist not found.</response>
+        /// <returns>The original playlist items.</returns>
+        [HttpGet("{playlistId}/Items")]
+        public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
+            [FromRoute] Guid playlistId,
+            [FromRoute] Guid userId,
+            [FromRoute] int? startIndex,
+            [FromRoute] int? limit,
+            [FromRoute] string fields,
+            [FromRoute] bool? enableImages,
+            [FromRoute] bool? enableUserData,
+            [FromRoute] int? imageTypeLimit,
+            [FromRoute] string enableImageTypes)
+        {
+            var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
+            if (playlist == null)
+            {
+                return NotFound();
+            }
+
+            var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+
+            var items = playlist.GetManageableItems().ToArray();
+
+            var count = items.Length;
+
+            if (startIndex.HasValue)
+            {
+                items = items.Skip(startIndex.Value).ToArray();
+            }
+
+            if (limit.HasValue)
+            {
+                items = items.Take(limit.Value).ToArray();
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
+
+            for (int index = 0; index < dtos.Count; index++)
+            {
+                dtos[index].PlaylistItemId = items[index].Item1.Id;
+            }
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                Items = dtos,
+                TotalRecordCount = count
+            };
+
+            return result;
+        }
+    }
+}

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

@@ -0,0 +1,200 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+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.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Plugins controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    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]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isAppStoreEnabled", Justification = "Imported from ServiceStack")]
+        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="204">Plugin uninstalled.</response>
+        /// <response code="404">Plugin not found.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+        [HttpDelete("{pluginId}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult UninstallPlugin([FromRoute] Guid pluginId)
+        {
+            var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
+            if (plugin == null)
+            {
+                return NotFound();
+            }
+
+            _installationManager.UninstallPlugin(plugin);
+            return NoContent();
+        }
+
+        /// <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")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        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="204">Plugin configuration updated.</response>
+        /// <response code="404">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="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/>
+        ///    when plugin not found or plugin doesn't have configuration.
+        /// </returns>
+        [HttpPost("{pluginId}/Configuration")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        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 NoContent();
+        }
+
+        /// <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")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        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="204">Plugin security info updated.</response>
+        /// <returns>An <see cref="NoContentResult"/>.</returns>
+        [Obsolete("This endpoint should not be used.")]
+        [HttpPost("SecurityInfo")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult UpdatePluginSecurityInfo([FromBody, BindRequired] PluginSecurityInfo pluginSecurityInfo)
+        {
+            return NoContent();
+        }
+
+        /// <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}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        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}")]
+        [ProducesResponseType(StatusCodes.Status501NotImplemented)]
+        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();
+        }
+    }
+}

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

@@ -0,0 +1,266 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+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(Policy = Policies.DefaultAuthorization)]
+    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="itemId">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("{itemId}/RemoteImages")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
+            [FromRoute] Guid itemId,
+            [FromQuery] ImageType? type,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string providerName,
+            [FromQuery] bool includeAllLanguages)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            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="itemId">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("{itemId}/RemoteImages/Providers")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            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="itemId">Item Id.</param>
+        /// <param name="type">The image type.</param>
+        /// <param name="imageUrl">The image url.</param>
+        /// <response code="204">Remote image downloaded.</response>
+        /// <response code="404">Remote image not found.</response>
+        /// <returns>Download status.</returns>
+        [HttpPost("{itemId}/RemoteImages/Download")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> DownloadRemoteImage(
+            [FromRoute] Guid itemId,
+            [FromQuery, BindRequired] ImageType type,
+            [FromQuery] string imageUrl)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
+                .ConfigureAwait(false);
+
+            item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
+            return NoContent();
+        }
+
+        /// <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;

+ 475 - 0
Jellyfin.Api/Controllers/SessionController.cs

@@ -0,0 +1,475 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The session controller.
+    /// </summary>
+    public class SessionController : BaseJellyfinApiController
+    {
+        private readonly ISessionManager _sessionManager;
+        private readonly IUserManager _userManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly IDeviceManager _deviceManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SessionController"/> class.
+        /// </summary>
+        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+        /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
+        /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+        public SessionController(
+            ISessionManager sessionManager,
+            IUserManager userManager,
+            IAuthorizationContext authContext,
+            IDeviceManager deviceManager)
+        {
+            _sessionManager = sessionManager;
+            _userManager = userManager;
+            _authContext = authContext;
+            _deviceManager = deviceManager;
+        }
+
+        /// <summary>
+        /// Gets a list of sessions.
+        /// </summary>
+        /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param>
+        /// <param name="deviceId">Filter by device Id.</param>
+        /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param>
+        /// <response code="200">List of sessions returned.</response>
+        /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns>
+        [HttpGet("/Sessions")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<SessionInfo>> GetSessions(
+            [FromQuery] Guid controllableByUserId,
+            [FromQuery] string deviceId,
+            [FromQuery] int? activeWithinSeconds)
+        {
+            var result = _sessionManager.Sessions;
+
+            if (!string.IsNullOrEmpty(deviceId))
+            {
+                result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
+            }
+
+            if (!controllableByUserId.Equals(Guid.Empty))
+            {
+                result = result.Where(i => i.SupportsRemoteControl);
+
+                var user = _userManager.GetUserById(controllableByUserId);
+
+                if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
+                {
+                    result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(controllableByUserId));
+                }
+
+                if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl))
+                {
+                    result = result.Where(i => !i.UserId.Equals(Guid.Empty));
+                }
+
+                if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0)
+                {
+                    var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value);
+                    result = result.Where(i => i.LastActivityDate >= minActiveDate);
+                }
+
+                result = result.Where(i =>
+                {
+                    if (!string.IsNullOrWhiteSpace(i.DeviceId))
+                    {
+                        if (!_deviceManager.CanAccessDevice(user, i.DeviceId))
+                        {
+                            return false;
+                        }
+                    }
+
+                    return true;
+                });
+            }
+
+            return Ok(result);
+        }
+
+        /// <summary>
+        /// Instructs a session to browse to an item or view.
+        /// </summary>
+        /// <param name="sessionId">The session Id.</param>
+        /// <param name="itemType">The type of item to browse to.</param>
+        /// <param name="itemId">The Id of the item.</param>
+        /// <param name="itemName">The name of the item.</param>
+        /// <response code="204">Instruction sent to session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/Viewing")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult DisplayContent(
+            [FromRoute] string sessionId,
+            [FromQuery] string itemType,
+            [FromQuery] string itemId,
+            [FromQuery] string itemName)
+        {
+            var command = new BrowseRequest
+            {
+                ItemId = itemId,
+                ItemName = itemName,
+                ItemType = itemType
+            };
+
+            _sessionManager.SendBrowseCommand(
+                RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
+                sessionId,
+                command,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Instructs a session to play an item.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="itemIds">The ids of the items to play, comma delimited.</param>
+        /// <param name="startPositionTicks">The starting position of the first item.</param>
+        /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param>
+        /// <param name="playRequest">The <see cref="PlayRequest"/>.</param>
+        /// <response code="204">Instruction sent to session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/Playing")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult Play(
+            [FromRoute] string sessionId,
+            [FromQuery] Guid[] itemIds,
+            [FromQuery] long? startPositionTicks,
+            [FromQuery] PlayCommand playCommand,
+            [FromBody, Required] PlayRequest playRequest)
+        {
+            if (playRequest == null)
+            {
+                throw new ArgumentException("Request Body may not be null");
+            }
+
+            playRequest.ItemIds = itemIds;
+            playRequest.StartPositionTicks = startPositionTicks;
+            playRequest.PlayCommand = playCommand;
+
+            _sessionManager.SendPlayCommand(
+                RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
+                sessionId,
+                playRequest,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Issues a playstate command to a client.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="playstateRequest">The <see cref="PlaystateRequest"/>.</param>
+        /// <response code="204">Playstate command sent to session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/Playing/{command}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SendPlaystateCommand(
+            [FromRoute] string sessionId,
+            [FromBody] PlaystateRequest playstateRequest)
+        {
+            _sessionManager.SendPlaystateCommand(
+                RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
+                sessionId,
+                playstateRequest,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Issues a system command to a client.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="command">The command to send.</param>
+        /// <response code="204">System command sent to session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/System/{command}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SendSystemCommand(
+            [FromRoute] string sessionId,
+            [FromRoute] string command)
+        {
+            var name = command;
+            if (Enum.TryParse(name, true, out GeneralCommandType commandType))
+            {
+                name = commandType.ToString();
+            }
+
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var generalCommand = new GeneralCommand
+            {
+                Name = name,
+                ControllingUserId = currentSession.UserId
+            };
+
+            _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Issues a general command to a client.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="command">The command to send.</param>
+        /// <response code="204">General command sent to session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/Command/{Command}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SendGeneralCommand(
+            [FromRoute] string sessionId,
+            [FromRoute] string command)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+
+            var generalCommand = new GeneralCommand
+            {
+                Name = command,
+                ControllingUserId = currentSession.UserId
+            };
+
+            _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Issues a full general command to a client.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="command">The <see cref="GeneralCommand"/>.</param>
+        /// <response code="204">Full general command sent to session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/Command")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SendFullGeneralCommand(
+            [FromRoute] string sessionId,
+            [FromBody, Required] GeneralCommand command)
+        {
+            var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+
+            if (command == null)
+            {
+                throw new ArgumentException("Request body may not be null");
+            }
+
+            command.ControllingUserId = currentSession.UserId;
+
+            _sessionManager.SendGeneralCommand(
+                currentSession.Id,
+                sessionId,
+                command,
+                CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Issues a command to a client to display a message to the user.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="text">The message test.</param>
+        /// <param name="header">The message header.</param>
+        /// <param name="timeoutMs">The message timeout. If omitted the user will have to confirm viewing the message.</param>
+        /// <response code="204">Message sent.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/Message")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult SendMessageCommand(
+            [FromRoute] string sessionId,
+            [FromQuery] string text,
+            [FromQuery] string header,
+            [FromQuery] long? timeoutMs)
+        {
+            var command = new MessageCommand
+            {
+                Header = string.IsNullOrEmpty(header) ? "Message from Server" : header,
+                TimeoutMs = timeoutMs,
+                Text = text
+            };
+
+            _sessionManager.SendMessageCommand(RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, sessionId, command, CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Adds an additional user to a session.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="userId">The user id.</param>
+        /// <response code="204">User added to session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/{sessionId}/User/{userId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult AddUserToSession(
+            [FromRoute] string sessionId,
+            [FromRoute] Guid userId)
+        {
+            _sessionManager.AddAdditionalUser(sessionId, userId);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Removes an additional user from a session.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="userId">The user id.</param>
+        /// <response code="204">User removed from session.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("/Sessions/{sessionId}/User/{userId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult RemoveUserFromSession(
+            [FromRoute] string sessionId,
+            [FromRoute] Guid userId)
+        {
+            _sessionManager.RemoveAdditionalUser(sessionId, userId);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates capabilities for a device.
+        /// </summary>
+        /// <param name="id">The session id.</param>
+        /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param>
+        /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param>
+        /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param>
+        /// <param name="supportsSync">Determines whether sync is supported.</param>
+        /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param>
+        /// <response code="204">Capabilities posted.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Capabilities")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult PostCapabilities(
+            [FromQuery] string id,
+            [FromQuery] string playableMediaTypes,
+            [FromQuery] string supportedCommands,
+            [FromQuery] bool supportsMediaControl,
+            [FromQuery] bool supportsSync,
+            [FromQuery] bool supportsPersistentIdentifier = true)
+        {
+            if (string.IsNullOrWhiteSpace(id))
+            {
+                id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            }
+
+            _sessionManager.ReportCapabilities(id, new ClientCapabilities
+            {
+                PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true),
+                SupportedCommands = RequestHelpers.Split(supportedCommands, ',', true),
+                SupportsMediaControl = supportsMediaControl,
+                SupportsSync = supportsSync,
+                SupportsPersistentIdentifier = supportsPersistentIdentifier
+            });
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates capabilities for a device.
+        /// </summary>
+        /// <param name="id">The session id.</param>
+        /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param>
+        /// <response code="204">Capabilities updated.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Capabilities/Full")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult PostFullCapabilities(
+            [FromQuery] string id,
+            [FromBody, Required] ClientCapabilities capabilities)
+        {
+            if (string.IsNullOrWhiteSpace(id))
+            {
+                id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            }
+
+            _sessionManager.ReportCapabilities(id, capabilities);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that a session is viewing an item.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <response code="204">Session reported to server.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Viewing")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult ReportViewing(
+            [FromQuery] string sessionId,
+            [FromQuery] string itemId)
+        {
+            string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+
+            _sessionManager.ReportNowViewingItem(session, itemId);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that a session has ended.
+        /// </summary>
+        /// <response code="204">Session end reported to server.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Logout")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult ReportSessionEnded()
+        {
+            AuthorizationInfo auth = _authContext.GetAuthorizationInfo(Request);
+
+            _sessionManager.Logout(auth.Token);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Get all auth providers.
+        /// </summary>
+        /// <response code="200">Auth providers retrieved.</response>
+        /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns>
+        [HttpGet("/Auth/Providers")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders()
+        {
+            return _userManager.GetAuthenticationProviders();
+        }
+
+        /// <summary>
+        /// Get all password reset providers.
+        /// </summary>
+        /// <response code="200">Password reset providers retrieved.</response>
+        /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns>
+        [HttpGet("/Auto/PasswordResetProviders")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()
+        {
+            return _userManager.GetPasswordResetProviders();
+        }
+    }
+}

+ 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();
         }
     }
 }

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

@@ -0,0 +1,347 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+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="itemId">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/{itemId}/Subtitles/{index}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<Task> DeleteSubtitle(
+            [FromRoute] Guid itemId,
+            [FromRoute] int index)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            _subtitleManager.DeleteSubtitles(item, index);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Search remote subtitles.
+        /// </summary>
+        /// <param name="itemId">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/{itemId}/RemoteSearch/Subtitles/{language}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
+            [FromRoute] Guid itemId,
+            [FromRoute] string language,
+            [FromQuery] bool? isPerfectMatch)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+
+            return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Downloads a remote subtitle.
+        /// </summary>
+        /// <param name="itemId">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/{itemId}/RemoteSearch/Subtitles/{subtitleId}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> DownloadRemoteSubtitles(
+            [FromRoute] Guid itemId,
+            [FromRoute] string subtitleId)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+
+            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="itemId">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="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>
+        /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
+        /// <response code="200">File returned.</response>
+        /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+        [HttpGet("/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
+        [HttpGet("/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult> GetSubtitle(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] string mediaSourceId,
+            [FromRoute, Required] int index,
+            [FromRoute, Required] string format,
+            [FromQuery] long? endPositionTicks,
+            [FromQuery] bool copyTimestamps,
+            [FromQuery] bool addVttTimeMap,
+            [FromRoute] long startPositionTicks = 0)
+        {
+            if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
+            {
+                format = "json";
+            }
+
+            if (string.IsNullOrEmpty(format))
+            {
+                var item = (Video)_libraryManager.GetItemById(itemId);
+
+                var idString = itemId.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(itemId, 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(
+                    itemId,
+                    mediaSourceId,
+                    index,
+                    format,
+                    startPositionTicks,
+                    endPositionTicks,
+                    copyTimestamps).ConfigureAwait(false),
+                MimeTypes.GetMimeType("file." + format));
+        }
+
+        /// <summary>
+        /// Gets an HLS subtitle playlist.
+        /// </summary>
+        /// <param name="itemId">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/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> GetSubtitlePlaylist(
+            [FromRoute] Guid itemId,
+            [FromRoute] int index,
+            [FromRoute] string mediaSourceId,
+            [FromQuery, Required] int segmentLength)
+        {
+            var item = (Video)_libraryManager.GetItemById(itemId);
+
+            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);
+        }
+    }
+}

+ 87 - 0
Jellyfin.Api/Controllers/SuggestionsController.cs

@@ -0,0 +1,87 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The suggestions controller.
+    /// </summary>
+    public class SuggestionsController : BaseJellyfinApiController
+    {
+        private readonly IDtoService _dtoService;
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SuggestionsController"/> class.
+        /// </summary>
+        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        public SuggestionsController(
+            IDtoService dtoService,
+            IUserManager userManager,
+            ILibraryManager libraryManager)
+        {
+            _dtoService = dtoService;
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+        }
+
+        /// <summary>
+        /// Gets suggestions.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="mediaType">The media types.</param>
+        /// <param name="type">The type.</param>
+        /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
+        /// <param name="startIndex">Optional. The start index.</param>
+        /// <param name="limit">Optional. The limit.</param>
+        /// <response code="200">Suggestions returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
+        [HttpGet("/Users/{userId}/Suggestions")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
+            [FromRoute] Guid userId,
+            [FromQuery] string? mediaType,
+            [FromQuery] string? type,
+            [FromQuery] bool enableTotalRecordCount,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit)
+        {
+            var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
+            {
+                OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+                MediaTypes = RequestHelpers.Split(mediaType!, ',', true),
+                IncludeItemTypes = RequestHelpers.Split(type!, ',', true),
+                IsVirtualItem = false,
+                StartIndex = startIndex,
+                Limit = limit,
+                DtoOptions = dtoOptions,
+                EnableTotalRecordCount = enableTotalRecordCount,
+                Recursive = true
+            });
+
+            var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = result.TotalRecordCount,
+                Items = dtoList
+            };
+        }
+    }
+}

+ 222 - 0
Jellyfin.Api/Controllers/SystemController.cs

@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.System;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The system controller.
+    /// </summary>
+    [Route("/System")]
+    public class SystemController : BaseJellyfinApiController
+    {
+        private readonly IServerApplicationHost _appHost;
+        private readonly IApplicationPaths _appPaths;
+        private readonly IFileSystem _fileSystem;
+        private readonly INetworkManager _network;
+        private readonly ILogger<SystemController> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SystemController"/> class.
+        /// </summary>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+        /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param>
+        /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param>
+        public SystemController(
+            IServerConfigurationManager serverConfigurationManager,
+            IServerApplicationHost appHost,
+            IFileSystem fileSystem,
+            INetworkManager network,
+            ILogger<SystemController> logger)
+        {
+            _appPaths = serverConfigurationManager.ApplicationPaths;
+            _appHost = appHost;
+            _fileSystem = fileSystem;
+            _network = network;
+            _logger = logger;
+        }
+
+        /// <summary>
+        /// Gets information about the server.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns>
+        [HttpGet("Info")]
+        [Authorize(Policy = Policies.IgnoreSchedule)]
+        [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<SystemInfo>> GetSystemInfo()
+        {
+            return await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets public information about the server.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns>
+        [HttpGet("Info/Public")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<PublicSystemInfo>> GetPublicSystemInfo()
+        {
+            return await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Pings the system.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>The server name.</returns>
+        [HttpGet("Ping")]
+        [HttpPost("Ping")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<string> PingSystem()
+        {
+            return _appHost.Name;
+        }
+
+        /// <summary>
+        /// Restarts the application.
+        /// </summary>
+        /// <response code="204">Server restarted.</response>
+        /// <returns>No content. Server restarted.</returns>
+        [HttpPost("Restart")]
+        [Authorize(Policy = Policies.LocalAccessOnly)]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult RestartApplication()
+        {
+            Task.Run(async () =>
+            {
+                await Task.Delay(100).ConfigureAwait(false);
+                _appHost.Restart();
+            });
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Shuts down the application.
+        /// </summary>
+        /// <response code="204">Server shut down.</response>
+        /// <returns>No content. Server shut down.</returns>
+        [HttpPost("Shutdown")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult ShutdownApplication()
+        {
+            Task.Run(async () =>
+            {
+                await Task.Delay(100).ConfigureAwait(false);
+                await _appHost.Shutdown().ConfigureAwait(false);
+            });
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Gets a list of available server log files.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns>
+        [HttpGet("Logs")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<LogFile[]> GetServerLogs()
+        {
+            IEnumerable<FileSystemMetadata> files;
+
+            try
+            {
+                files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false);
+            }
+            catch (IOException ex)
+            {
+                _logger.LogError(ex, "Error getting logs");
+                files = Enumerable.Empty<FileSystemMetadata>();
+            }
+
+            var result = files.Select(i => new LogFile
+                {
+                    DateCreated = _fileSystem.GetCreationTimeUtc(i),
+                    DateModified = _fileSystem.GetLastWriteTimeUtc(i),
+                    Name = i.Name,
+                    Size = i.Length
+                })
+                .OrderByDescending(i => i.DateModified)
+                .ThenByDescending(i => i.DateCreated)
+                .ThenBy(i => i.Name)
+                .ToArray();
+
+            return result;
+        }
+
+        /// <summary>
+        /// Gets information about the request endpoint.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns>
+        [HttpGet("Endpoint")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<EndPointInfo> GetEndpointInfo()
+        {
+            return new EndPointInfo
+            {
+                IsLocal = Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress),
+                IsInNetwork = _network.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString())
+            };
+        }
+
+        /// <summary>
+        /// Gets a log file.
+        /// </summary>
+        /// <param name="name">The name of the log file to get.</param>
+        /// <response code="200">Log file retrieved.</response>
+        /// <returns>The log file.</returns>
+        [HttpGet("Logs/Log")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult GetLogFile([FromQuery, Required] string name)
+        {
+            var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
+                .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+
+            // For older files, assume fully static
+            var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite;
+
+            FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare);
+            return File(stream, "text/plain");
+        }
+
+        /// <summary>
+        /// Gets wake on lan information.
+        /// </summary>
+        /// <response code="200">Information retrieved.</response>
+        /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns>
+        [HttpGet("WakeOnLanInfo")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo()
+        {
+            var result = _appHost.GetWakeOnLanInfo();
+            return Ok(result);
+        }
+    }
+}

+ 380 - 0
Jellyfin.Api/Controllers/TvShowsController.cs

@@ -0,0 +1,380 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.TV;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The tv shows controller.
+    /// </summary>
+    [Route("/Shows")]
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class TvShowsController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IDtoService _dtoService;
+        private readonly ITVSeriesManager _tvSeriesManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TvShowsController"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+        /// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param>
+        public TvShowsController(
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            ITVSeriesManager tvSeriesManager)
+        {
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+            _tvSeriesManager = tvSeriesManager;
+        }
+
+        /// <summary>
+        /// Gets a list of next up episodes.
+        /// </summary>
+        /// <param name="userId">The user id of the user to get the next up episodes for.</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="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+        /// <param name="seriesId">Optional. Filter by series 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="enableImges">Optional. Include image information in output.</param>
+        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+        /// <param name="enableUserData">Optional. Include user data.</param>
+        /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
+        [HttpGet("NextUp")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
+            [FromQuery] Guid userId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] string? seriesId,
+            [FromQuery] string? parentId,
+            [FromQuery] bool? enableImges,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var options = new DtoOptions()
+                .AddItemFields(fields!)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            var result = _tvSeriesManager.GetNextUp(
+                new NextUpQuery
+                {
+                    Limit = limit,
+                    ParentId = parentId,
+                    SeriesId = seriesId,
+                    StartIndex = startIndex,
+                    UserId = userId,
+                    EnableTotalRecordCount = enableTotalRecordCount
+                },
+                options);
+
+            var user = _userManager.GetUserById(userId);
+
+            var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = result.TotalRecordCount,
+                Items = returnItems
+            };
+        }
+
+        /// <summary>
+        /// Gets a list of upcoming episodes.
+        /// </summary>
+        /// <param name="userId">The user id of the user to get the upcoming episodes for.</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="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImges">Optional. Include image information in output.</param>
+        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+        /// <param name="enableUserData">Optional. Include user data.</param>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
+        [HttpGet("Upcoming")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes(
+            [FromQuery] Guid userId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] string? parentId,
+            [FromQuery] bool? enableImges,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1);
+
+            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+
+            var options = new DtoOptions()
+                .AddItemFields(fields!)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = new[] { nameof(Episode) },
+                OrderBy = new[] { ItemSortBy.PremiereDate, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(),
+                MinPremiereDate = minPremiereDate,
+                StartIndex = startIndex,
+                Limit = limit,
+                ParentId = parentIdGuid,
+                Recursive = true,
+                DtoOptions = options
+            });
+
+            var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = itemsResult.Count,
+                Items = returnItems
+            };
+        }
+
+        /// <summary>
+        /// Gets episodes for a tv season.
+        /// </summary>
+        /// <param name="seriesId">The series id.</param>
+        /// <param name="userId">The user id.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+        /// <param name="season">Optional filter by season number.</param>
+        /// <param name="seasonId">Optional. Filter by season id.</param>
+        /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
+        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+        /// <param name="startItemId">Optional. Skip through the list until a given item is found.</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="enableImages">Optional, include image information in output.</param>
+        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+        /// <param name="enableUserData">Optional. Include user data.</param>
+        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+        /// <param name="sortOrder">Optional. Sort order: Ascending,Descending.</param>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
+        [HttpGet("{seriesId}/Episodes")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "sortOrder", Justification = "Imported from ServiceStack")]
+        public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
+            [FromRoute] string seriesId,
+            [FromQuery] Guid userId,
+            [FromQuery] string? fields,
+            [FromQuery] int? season,
+            [FromQuery] string? seasonId,
+            [FromQuery] bool? isMissing,
+            [FromQuery] string? adjacentTo,
+            [FromQuery] string? startItemId,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] bool? enableImages,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] string? sortBy,
+            [FromQuery] SortOrder? sortOrder)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            List<BaseItem> episodes;
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields!)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            if (!string.IsNullOrWhiteSpace(seasonId)) // Season id was supplied. Get episodes by season id.
+            {
+                var item = _libraryManager.GetItemById(new Guid(seasonId));
+                if (!(item is Season seasonItem))
+                {
+                    return NotFound("No season exists with Id " + seasonId);
+                }
+
+                episodes = seasonItem.GetEpisodes(user, dtoOptions);
+            }
+            else if (season.HasValue) // Season number was supplied. Get episodes by season number
+            {
+                if (!(_libraryManager.GetItemById(seriesId) is Series series))
+                {
+                    return NotFound("Series not found");
+                }
+
+                var seasonItem = series
+                    .GetSeasons(user, dtoOptions)
+                    .FirstOrDefault(i => i.IndexNumber == season.Value);
+
+                episodes = seasonItem == null ?
+                    new List<BaseItem>()
+                    : ((Season)seasonItem).GetEpisodes(user, dtoOptions);
+            }
+            else // No season number or season id was supplied. Returning all episodes.
+            {
+                if (!(_libraryManager.GetItemById(seriesId) is Series series))
+                {
+                    return NotFound("Series not found");
+                }
+
+                episodes = series.GetEpisodes(user, dtoOptions).ToList();
+            }
+
+            // Filter after the fact in case the ui doesn't want them
+            if (isMissing.HasValue)
+            {
+                var val = isMissing.Value;
+                episodes = episodes
+                    .Where(i => ((Episode)i).IsMissingEpisode == val)
+                    .ToList();
+            }
+
+            if (!string.IsNullOrWhiteSpace(startItemId))
+            {
+                episodes = episodes
+                    .SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), startItemId, StringComparison.OrdinalIgnoreCase))
+                    .ToList();
+            }
+
+            // This must be the last filter
+            if (!string.IsNullOrEmpty(adjacentTo))
+            {
+                episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo).ToList();
+            }
+
+            if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase))
+            {
+                episodes.Shuffle();
+            }
+
+            var returnItems = episodes;
+
+            if (startIndex.HasValue || limit.HasValue)
+            {
+                returnItems = ApplyPaging(episodes, startIndex, limit).ToList();
+            }
+
+            var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = episodes.Count,
+                Items = dtos
+            };
+        }
+
+        /// <summary>
+        /// Gets seasons for a tv series.
+        /// </summary>
+        /// <param name="seriesId">The series id.</param>
+        /// <param name="userId">The user id.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+        /// <param name="isSpecialSeason">Optional. Filter by special season.</param>
+        /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
+        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+        /// <param name="enableImages">Optional. Include image information in output.</param>
+        /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+        /// <param name="enableUserData">Optional. Include user data.</param>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
+        [HttpGet("{seriesId}/Seasons")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
+            [FromRoute] string seriesId,
+            [FromQuery] Guid userId,
+            [FromQuery] string fields,
+            [FromQuery] bool? isSpecialSeason,
+            [FromQuery] bool? isMissing,
+            [FromQuery] string adjacentTo,
+            [FromQuery] bool? enableImages,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (!(_libraryManager.GetItemById(seriesId) is Series series))
+            {
+                return NotFound("Series not found");
+            }
+
+            var seasons = series.GetItemList(new InternalItemsQuery(user)
+            {
+                IsMissing = isMissing,
+                IsSpecialSeason = isSpecialSeason,
+                AdjacentTo = adjacentTo
+            });
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+
+            var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = returnItems.Count,
+                Items = returnItems
+            };
+        }
+
+        /// <summary>
+        /// Applies the paging.
+        /// </summary>
+        /// <param name="items">The items.</param>
+        /// <param name="startIndex">The start index.</param>
+        /// <param name="limit">The limit.</param>
+        /// <returns>IEnumerable{BaseItem}.</returns>
+        private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit)
+        {
+            // Start at
+            if (startIndex.HasValue)
+            {
+                items = items.Skip(startIndex.Value);
+            }
+
+            // Return limit
+            if (limit.HasValue)
+            {
+                items = items.Take(limit.Value);
+            }
+
+            return items;
+        }
+    }
+}

+ 552 - 0
Jellyfin.Api/Controllers/UserController.cs

@@ -0,0 +1,552 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.UserDtos;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Users;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// User controller.
+    /// </summary>
+    [Route("/Users")]
+    public class UserController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly ISessionManager _sessionManager;
+        private readonly INetworkManager _networkManager;
+        private readonly IDeviceManager _deviceManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly IServerConfigurationManager _config;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="UserController"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public UserController(
+            IUserManager userManager,
+            ISessionManager sessionManager,
+            INetworkManager networkManager,
+            IDeviceManager deviceManager,
+            IAuthorizationContext authContext,
+            IServerConfigurationManager config)
+        {
+            _userManager = userManager;
+            _sessionManager = sessionManager;
+            _networkManager = networkManager;
+            _deviceManager = deviceManager;
+            _authContext = authContext;
+            _config = config;
+        }
+
+        /// <summary>
+        /// Gets a list of users.
+        /// </summary>
+        /// <param name="isHidden">Optional filter by IsHidden=true or false.</param>
+        /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param>
+        /// <param name="isGuest">Optional filter by IsGuest=true or false.</param>
+        /// <response code="200">Users returned.</response>
+        /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns>
+        [HttpGet]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isGuest", Justification = "Imported from ServiceStack")]
+        public ActionResult<IEnumerable<UserDto>> GetUsers(
+            [FromQuery] bool? isHidden,
+            [FromQuery] bool? isDisabled,
+            [FromQuery] bool? isGuest)
+        {
+            var users = Get(isHidden, isDisabled, false, false);
+            return Ok(users);
+        }
+
+        /// <summary>
+        /// Gets a list of publicly visible users for display on a login screen.
+        /// </summary>
+        /// <response code="200">Public users returned.</response>
+        /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns>
+        [HttpGet("Public")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<UserDto>> GetPublicUsers()
+        {
+            // If the startup wizard hasn't been completed then just return all users
+            if (!_config.Configuration.IsStartupWizardCompleted)
+            {
+                return Ok(Get(false, false, false, false));
+            }
+
+            return Ok(Get(false, false, true, true));
+        }
+
+        /// <summary>
+        /// Gets a user by Id.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <response code="200">User returned.</response>
+        /// <response code="404">User not found.</response>
+        /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns>
+        [HttpGet("{userId}")]
+        [Authorize(Policy = Policies.IgnoreSchedule)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<UserDto> GetUserById([FromRoute] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            var result = _userManager.GetUserDto(user, HttpContext.Connection.RemoteIpAddress.ToString());
+            return result;
+        }
+
+        /// <summary>
+        /// Deletes a user.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <response code="200">User deleted.</response>
+        /// <response code="404">User not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns>
+        [HttpDelete("{userId}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult DeleteUser([FromRoute] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            _sessionManager.RevokeUserTokens(user.Id, null);
+            _userManager.DeleteUser(user);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Authenticates a user.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="pw">The password as plain text.</param>
+        /// <param name="password">The password sha1-hash.</param>
+        /// <response code="200">User authenticated.</response>
+        /// <response code="403">Sha1-hashed password only is not allowed.</response>
+        /// <response code="404">User not found.</response>
+        /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns>
+        [HttpPost("{userId}/Authenticate")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
+            [FromRoute, Required] Guid userId,
+            [FromQuery, BindRequired] string pw,
+            [FromQuery, BindRequired] string password)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw))
+            {
+                return Forbid("Only sha1 password is not allowed.");
+            }
+
+            // Password should always be null
+            AuthenticateUserByName request = new AuthenticateUserByName
+            {
+                Username = user.Username,
+                Password = null,
+                Pw = pw
+            };
+            return await AuthenticateUserByName(request).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Authenticates a user by name.
+        /// </summary>
+        /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param>
+        /// <response code="200">User authenticated.</response>
+        /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
+        [HttpPost("AuthenticateByName")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, BindRequired] AuthenticateUserByName request)
+        {
+            var auth = _authContext.GetAuthorizationInfo(Request);
+
+            try
+            {
+                var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest
+                {
+                    App = auth.Client,
+                    AppVersion = auth.Version,
+                    DeviceId = auth.DeviceId,
+                    DeviceName = auth.Device,
+                    Password = request.Pw,
+                    PasswordSha1 = request.Password,
+                    RemoteEndPoint = HttpContext.Connection.RemoteIpAddress.ToString(),
+                    Username = request.Username
+                }).ConfigureAwait(false);
+
+                return result;
+            }
+            catch (SecurityException e)
+            {
+                // rethrow adding IP address to message
+                throw new SecurityException($"[{HttpContext.Connection.RemoteIpAddress}] {e.Message}", e);
+            }
+        }
+
+        /// <summary>
+        /// Updates a user's password.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
+        /// <response code="200">Password successfully reset.</response>
+        /// <response code="403">User is not allowed to update the password.</response>
+        /// <response code="404">User not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
+        [HttpPost("{userId}/Password")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> UpdateUserPassword(
+            [FromRoute] Guid userId,
+            [FromBody] UpdateUserPassword request)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to update the password.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            if (request.ResetPassword)
+            {
+                await _userManager.ResetPassword(user).ConfigureAwait(false);
+            }
+            else
+            {
+                var success = await _userManager.AuthenticateUser(
+                    user.Username,
+                    request.CurrentPw,
+                    request.CurrentPw,
+                    HttpContext.Connection.RemoteIpAddress.ToString(),
+                    false).ConfigureAwait(false);
+
+                if (success == null)
+                {
+                    return Forbid("Invalid user or password entered.");
+                }
+
+                await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false);
+
+                var currentToken = _authContext.GetAuthorizationInfo(Request).Token;
+
+                _sessionManager.RevokeUserTokens(user.Id, currentToken);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates a user's easy password.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param>
+        /// <response code="200">Password successfully reset.</response>
+        /// <response code="403">User is not allowed to update the password.</response>
+        /// <response code="404">User not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
+        [HttpPost("{userId}/EasyPassword")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult UpdateUserEasyPassword(
+            [FromRoute] Guid userId,
+            [FromBody] UpdateUserEasyPassword request)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to update the easy password.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            if (user == null)
+            {
+                return NotFound("User not found");
+            }
+
+            if (request.ResetPassword)
+            {
+                _userManager.ResetEasyPassword(user);
+            }
+            else
+            {
+                _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates a user.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="updateUser">The updated user model.</param>
+        /// <response code="204">User updated.</response>
+        /// <response code="400">User information was not supplied.</response>
+        /// <response code="403">User update forbidden.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
+        [HttpPost("{userId}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status400BadRequest)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        public async Task<ActionResult> UpdateUser(
+            [FromRoute] Guid userId,
+            [FromBody] UserDto updateUser)
+        {
+            if (updateUser == null)
+            {
+                return BadRequest();
+            }
+
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
+            {
+                return Forbid("User update not allowed.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
+            {
+                await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+                _userManager.UpdateConfiguration(user.Id, updateUser.Configuration);
+            }
+            else
+            {
+                await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
+                _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates a user policy.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="newPolicy">The new user policy.</param>
+        /// <response code="204">User policy updated.</response>
+        /// <response code="400">User policy was not supplied.</response>
+        /// <response code="403">User policy update forbidden.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns>
+        [HttpPost("{userId}/Policy")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status400BadRequest)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        public ActionResult UpdateUserPolicy(
+            [FromRoute] Guid userId,
+            [FromBody] UserPolicy newPolicy)
+        {
+            if (newPolicy == null)
+            {
+                return BadRequest();
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            // If removing admin access
+            if (!(newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)))
+            {
+                if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
+                {
+                    return Forbid("There must be at least one user in the system with administrative access.");
+                }
+            }
+
+            // If disabling
+            if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
+            {
+                return Forbid("Administrators cannot be disabled.");
+            }
+
+            // If disabling
+            if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
+            {
+                if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
+                {
+                    return Forbid("There must be at least one enabled user in the system.");
+                }
+
+                var currentToken = _authContext.GetAuthorizationInfo(Request).Token;
+                _sessionManager.RevokeUserTokens(user.Id, currentToken);
+            }
+
+            _userManager.UpdatePolicy(userId, newPolicy);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates a user configuration.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="userConfig">The new user configuration.</param>
+        /// <response code="204">User configuration updated.</response>
+        /// <response code="403">User configuration update forbidden.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("{userId}/Configuration")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        public ActionResult UpdateUserConfiguration(
+            [FromRoute] Guid userId,
+            [FromBody] UserConfiguration userConfig)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
+            {
+                return Forbid("User configuration update not allowed");
+            }
+
+            _userManager.UpdateConfiguration(userId, userConfig);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Creates a user.
+        /// </summary>
+        /// <param name="request">The create user by name request body.</param>
+        /// <response code="200">User created.</response>
+        /// <returns>An <see cref="UserDto"/> of the new user.</returns>
+        [HttpPost("/Users/New")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<UserDto>> CreateUserByName([FromBody] CreateUserByName request)
+        {
+            var newUser = _userManager.CreateUser(request.Name);
+
+            // no need to authenticate password for new user
+            if (request.Password != null)
+            {
+                await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
+            }
+
+            var result = _userManager.GetUserDto(newUser, HttpContext.Connection.RemoteIpAddress.ToString());
+
+            return result;
+        }
+
+        /// <summary>
+        /// Initiates the forgot password process for a local user.
+        /// </summary>
+        /// <param name="enteredUsername">The entered username.</param>
+        /// <response code="200">Password reset process started.</response>
+        /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
+        [HttpPost("ForgotPassword")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string enteredUsername)
+        {
+            var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress)
+                          || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString());
+
+            var result = await _userManager.StartForgotPasswordProcess(enteredUsername, isLocal).ConfigureAwait(false);
+
+            return result;
+        }
+
+        /// <summary>
+        /// Redeems a forgot password pin.
+        /// </summary>
+        /// <param name="pin">The pin.</param>
+        /// <response code="200">Pin reset process started.</response>
+        /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
+        [HttpPost("ForgotPassword/Pin")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string pin)
+        {
+            var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false);
+            return result;
+        }
+
+        private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
+        {
+            var users = _userManager.Users;
+
+            if (isDisabled.HasValue)
+            {
+                users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value);
+            }
+
+            if (isHidden.HasValue)
+            {
+                users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value);
+            }
+
+            if (filterByDevice)
+            {
+                var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId;
+
+                if (!string.IsNullOrWhiteSpace(deviceId))
+                {
+                    users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId));
+                }
+            }
+
+            if (filterByNetwork)
+            {
+                if (!_networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString()))
+                {
+                    users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
+                }
+            }
+
+            var result = users
+                .OrderBy(u => u.Username)
+                .Select(i => _userManager.GetUserDto(i, HttpContext.Connection.RemoteIpAddress.ToString()));
+
+            return result;
+        }
+    }
+}

+ 3 - 4
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;
@@ -45,7 +44,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">Attachment retrieved.</response>
         /// <response code="404">Video or attachment not found.</response>
         /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
-        [HttpGet("{VideoID}/{MediaSourceID}/Attachments/{Index}")]
+        [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
         [Produces(MediaTypeNames.Application.Octet)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]

+ 202 - 0
Jellyfin.Api/Controllers/VideosController.cs

@@ -0,0 +1,202 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The videos controller.
+    /// </summary>
+    [Route("Videos")]
+    public class VideosController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
+        private readonly IDtoService _dtoService;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="VideosController"/> class.
+        /// </summary>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+        public VideosController(
+            ILibraryManager libraryManager,
+            IUserManager userManager,
+            IDtoService dtoService)
+        {
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dtoService = dtoService;
+        }
+
+        /// <summary>
+        /// Gets additional parts for a video.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <response code="200">Additional parts returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns>
+        [HttpGet("{itemId}/AdditionalParts")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute] Guid itemId, [FromQuery] Guid userId)
+        {
+            var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+
+            var item = itemId.Equals(Guid.Empty)
+                ? (!userId.Equals(Guid.Empty)
+                    ? _libraryManager.GetUserRootFolder()
+                    : _libraryManager.RootFolder)
+                : _libraryManager.GetItemById(itemId);
+
+            var dtoOptions = new DtoOptions();
+            dtoOptions = dtoOptions.AddClientFields(Request);
+
+            BaseItemDto[] items;
+            if (item is Video video)
+            {
+                items = video.GetAdditionalParts()
+                    .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
+                    .ToArray();
+            }
+            else
+            {
+                items = Array.Empty<BaseItemDto>();
+            }
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                Items = items,
+                TotalRecordCount = items.Length
+            };
+
+            return result;
+        }
+
+        /// <summary>
+        /// Removes alternate video sources.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <response code="204">Alternate sources deleted.</response>
+        /// <response code="404">Video not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns>
+        [HttpDelete("{itemId}/AlternateSources")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult DeleteAlternateSources([FromRoute] Guid itemId)
+        {
+            var video = (Video)_libraryManager.GetItemById(itemId);
+
+            if (video == null)
+            {
+                return NotFound("The video either does not exist or the id does not belong to a video.");
+            }
+
+            foreach (var link in video.GetLinkedAlternateVersions())
+            {
+                link.SetPrimaryVersionId(null);
+                link.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+
+                link.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+            }
+
+            video.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+            video.SetPrimaryVersionId(null);
+            video.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Merges videos into a single record.
+        /// </summary>
+        /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param>
+        /// <response code="204">Videos merged.</response>
+        /// <response code="400">Supply at least 2 video ids.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns>
+        [HttpPost("MergeVersions")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status400BadRequest)]
+        public ActionResult MergeVersions([FromQuery] string itemIds)
+        {
+            var items = RequestHelpers.Split(itemIds, ',', true)
+                .Select(i => _libraryManager.GetItemById(i))
+                .OfType<Video>()
+                .OrderBy(i => i.Id)
+                .ToList();
+
+            if (items.Count < 2)
+            {
+                return BadRequest("Please supply at least two videos to merge.");
+            }
+
+            var videosWithVersions = items.Where(i => i.MediaSourceCount > 1).ToList();
+
+            var primaryVersion = videosWithVersions.FirstOrDefault();
+            if (primaryVersion == null)
+            {
+                primaryVersion = items
+                    .OrderBy(i =>
+                    {
+                        if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile)
+                        {
+                            return 1;
+                        }
+
+                        return 0;
+                    })
+                    .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0)
+                    .First();
+            }
+
+            var list = primaryVersion.LinkedAlternateVersions.ToList();
+
+            foreach (var item in items.Where(i => i.Id != primaryVersion.Id))
+            {
+                item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture));
+
+                item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+
+                list.Add(new LinkedChild
+                {
+                    Path = item.Path,
+                    ItemId = item.Id
+                });
+
+                foreach (var linkedItem in item.LinkedAlternateVersions)
+                {
+                    if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
+                    {
+                        list.Add(linkedItem);
+                    }
+                }
+
+                if (item.LinkedAlternateVersions.Length > 0)
+                {
+                    item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+                    item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+                }
+            }
+
+            primaryVersion.LinkedAlternateVersions = list.ToArray();
+            primaryVersion.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
+            return NoContent();
+        }
+    }
+}

+ 162 - 0
Jellyfin.Api/Extensions/DtoExtensions.cs

@@ -0,0 +1,162 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Extensions
+{
+    /// <summary>
+    /// Dto Extensions.
+    /// </summary>
+    public static class DtoExtensions
+    {
+        /// <summary>
+        /// Add Dto Item fields.
+        /// </summary>
+        /// <remarks>
+        /// Converted from IHasItemFields.
+        /// Legacy order: 1.
+        /// </remarks>
+        /// <param name="dtoOptions">DtoOptions object.</param>
+        /// <param name="fields">Comma delimited string of fields.</param>
+        /// <returns>Modified DtoOptions object.</returns>
+        internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string fields)
+        {
+            if (string.IsNullOrEmpty(fields))
+            {
+                dtoOptions.Fields = Array.Empty<ItemFields>();
+            }
+            else
+            {
+                dtoOptions.Fields = fields.Split(',')
+                    .Select(v =>
+                    {
+                        if (Enum.TryParse(v, true, out ItemFields value))
+                        {
+                            return (ItemFields?)value;
+                        }
+
+                        return null;
+                    })
+                    .Where(i => i.HasValue)
+                    .Select(i => i!.Value)
+                    .ToArray();
+            }
+
+            return dtoOptions;
+        }
+
+        /// <summary>
+        /// Add additional fields depending on client.
+        /// </summary>
+        /// <remarks>
+        /// Use in place of GetDtoOptions.
+        /// Legacy order: 2.
+        /// </remarks>
+        /// <param name="dtoOptions">DtoOptions object.</param>
+        /// <param name="request">Current request.</param>
+        /// <returns>Modified DtoOptions object.</returns>
+        internal static DtoOptions AddClientFields(
+            this DtoOptions dtoOptions, HttpRequest request)
+        {
+            dtoOptions.Fields ??= Array.Empty<ItemFields>();
+
+            string? client = ClaimHelpers.GetClient(request.HttpContext.User);
+
+            // No client in claim
+            if (string.IsNullOrEmpty(client))
+            {
+                return dtoOptions;
+            }
+
+            if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount))
+            {
+                if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1)
+                {
+                    int oldLen = dtoOptions.Fields.Length;
+                    var arr = new ItemFields[oldLen + 1];
+                    dtoOptions.Fields.CopyTo(arr, 0);
+                    arr[oldLen] = ItemFields.RecursiveItemCount;
+                    dtoOptions.Fields = arr;
+                }
+            }
+
+            if (!dtoOptions.ContainsField(ItemFields.ChildCount))
+            {
+                if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 ||
+                    client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1)
+                {
+                    int oldLen = dtoOptions.Fields.Length;
+                    var arr = new ItemFields[oldLen + 1];
+                    dtoOptions.Fields.CopyTo(arr, 0);
+                    arr[oldLen] = ItemFields.ChildCount;
+                    dtoOptions.Fields = arr;
+                }
+            }
+
+            return dtoOptions;
+        }
+
+        /// <summary>
+        /// Add additional DtoOptions.
+        /// </summary>
+        /// <remarks>
+        /// Converted from IHasDtoOptions.
+        /// Legacy order: 3.
+        /// </remarks>
+        /// <param name="dtoOptions">DtoOptions object.</param>
+        /// <param name="enableImages">Enable images.</param>
+        /// <param name="enableUserData">Enable user data.</param>
+        /// <param name="imageTypeLimit">Image type limit.</param>
+        /// <param name="enableImageTypes">Enable image types.</param>
+        /// <returns>Modified DtoOptions object.</returns>
+        internal static DtoOptions AddAdditionalDtoOptions(
+            this DtoOptions dtoOptions,
+            bool? enableImages,
+            bool? enableUserData,
+            int? imageTypeLimit,
+            string enableImageTypes)
+        {
+            dtoOptions.EnableImages = enableImages ?? true;
+
+            if (imageTypeLimit.HasValue)
+            {
+                dtoOptions.ImageTypeLimit = imageTypeLimit.Value;
+            }
+
+            if (enableUserData.HasValue)
+            {
+                dtoOptions.EnableUserData = enableUserData.Value;
+            }
+
+            if (!string.IsNullOrWhiteSpace(enableImageTypes))
+            {
+                dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+                    .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true))
+                    .ToArray();
+            }
+
+            return dtoOptions;
+        }
+
+        /// <summary>
+        /// Check if DtoOptions contains field.
+        /// </summary>
+        /// <param name="dtoOptions">DtoOptions object.</param>
+        /// <param name="field">Field to check.</param>
+        /// <returns>Field existence.</returns>
+        internal static bool ContainsField(this DtoOptions dtoOptions, ItemFields field)
+            => dtoOptions.Fields != null && dtoOptions.Fields.Contains(field);
+    }
+}

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

@@ -0,0 +1,75 @@
+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();
+        }
+    }
+}

+ 65 - 0
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -1,8 +1,12 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using Jellyfin.Data.Enums;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using Microsoft.AspNetCore.Http;
 
 namespace Jellyfin.Api.Helpers
 {
@@ -104,5 +108,66 @@ namespace Jellyfin.Api.Helpers
                 ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
                 : value.Split(separator);
         }
+
+        /// <summary>
+        /// Checks if the user can update an entry.
+        /// </summary>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="requestContext">The <see cref="HttpRequest"/>.</param>
+        /// <param name="userId">The user id.</param>
+        /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
+        /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
+        internal static bool AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences)
+        {
+            var auth = authContext.GetAuthorizationInfo(requestContext);
+
+            var authenticatedUser = auth.User;
+
+            // If they're going to update the record of another user, they must be an administrator
+            if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator))
+                || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess))
+            {
+                return false;
+            }
+
+            return true;
+        }
+
+        internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request)
+        {
+            var authorization = authContext.GetAuthorizationInfo(request);
+            var user = authorization.User;
+            var session = sessionManager.LogSessionActivity(
+                authorization.Client,
+                authorization.Version,
+                authorization.DeviceId,
+                authorization.Device,
+                request.HttpContext.Connection.RemoteIpAddress.ToString(),
+                user);
+
+            if (session == null)
+            {
+                throw new ArgumentException("Session not found.");
+            }
+
+            return session;
+        }
+
+        /// <summary>
+        /// Get Guid array from string.
+        /// </summary>
+        /// <param name="value">String value.</param>
+        /// <returns>Guid array.</returns>
+        internal static Guid[] GetGuids(string? value)
+        {
+            if (value == null)
+            {
+                return Array.Empty<Guid>();
+            }
+
+            return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+                .Select(i => new Guid(i))
+                .ToArray();
+        }
     }
 }

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

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

+ 30 - 8
MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs → Jellyfin.Api/Models/ConfigurationPageInfo.cs

@@ -1,13 +1,18 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Plugins;
 using MediaBrowser.Controller.Plugins;
 using MediaBrowser.Model.Plugins;
 
-namespace MediaBrowser.WebDashboard.Api
+namespace Jellyfin.Api.Models
 {
+    /// <summary>
+    /// The configuration page info.
+    /// </summary>
     public class ConfigurationPageInfo
     {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class.
+        /// </summary>
+        /// <param name="page">Instance of <see cref="IPluginConfigurationPage"/> interface.</param>
         public ConfigurationPageInfo(IPluginConfigurationPage page)
         {
             Name = page.Name;
@@ -22,6 +27,11 @@ namespace MediaBrowser.WebDashboard.Api
             }
         }
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class.
+        /// </summary>
+        /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param>
+        /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param>
         public ConfigurationPageInfo(IPlugin plugin, PluginPageInfo page)
         {
             Name = page.Name;
@@ -40,13 +50,25 @@ namespace MediaBrowser.WebDashboard.Api
         /// <value>The name.</value>
         public string Name { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether the configurations page is enabled in the main menu.
+        /// </summary>
         public bool EnableInMainMenu { get; set; }
 
-        public string MenuSection { get; set; }
+        /// <summary>
+        /// Gets or sets the menu section.
+        /// </summary>
+        public string? MenuSection { get; set; }
 
-        public string MenuIcon { get; set; }
+        /// <summary>
+        /// Gets or sets the menu icon.
+        /// </summary>
+        public string? MenuIcon { get; set; }
 
-        public string DisplayName { get; set; }
+        /// <summary>
+        /// Gets or sets the display name.
+        /// </summary>
+        public string? DisplayName { get; set; }
 
         /// <summary>
         /// Gets or sets the type of the configuration page.
@@ -58,6 +80,6 @@ namespace MediaBrowser.WebDashboard.Api
         /// Gets or sets the plugin id.
         /// </summary>
         /// <value>The plugin id.</value>
-        public string PluginId { get; set; }
+        public string? PluginId { get; set; }
     }
 }

+ 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

+ 30 - 0
Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs

@@ -0,0 +1,30 @@
+using System;
+
+namespace Jellyfin.Api.Models.PlaylistDtos
+{
+    /// <summary>
+    /// Create new playlist dto.
+    /// </summary>
+    public class CreatePlaylistDto
+    {
+        /// <summary>
+        /// Gets or sets the name of the new playlist.
+        /// </summary>
+        public string? Name { get; set; }
+
+        /// <summary>
+        /// Gets or sets item ids to add to the playlist.
+        /// </summary>
+        public string? Ids { get; set; }
+
+        /// <summary>
+        /// Gets or sets the user id.
+        /// </summary>
+        public Guid UserId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the media type.
+        /// </summary>
+        public string? MediaType { get; set; }
+    }
+}

+ 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; }
     }
 }

+ 23 - 0
Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs

@@ -0,0 +1,23 @@
+namespace Jellyfin.Api.Models.UserDtos
+{
+    /// <summary>
+    /// The authenticate user by name request body.
+    /// </summary>
+    public class AuthenticateUserByName
+    {
+        /// <summary>
+        /// Gets or sets the username.
+        /// </summary>
+        public string? Username { get; set; }
+
+        /// <summary>
+        /// Gets or sets the plain text password.
+        /// </summary>
+        public string? Pw { get; set; }
+
+        /// <summary>
+        /// Gets or sets the sha1-hashed password.
+        /// </summary>
+        public string? Password { get; set; }
+    }
+}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä