Prechádzať zdrojové kódy

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

crobibero 5 rokov pred
rodič
commit
14faebc7fe
71 zmenil súbory, kde vykonal 6773 pridanie a 4602 odobranie
  1. 10 1
      Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
  2. 45 0
      Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs
  3. 11 0
      Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs
  4. 5 0
      Jellyfin.Api/Constants/Policies.cs
  5. 1 4
      Jellyfin.Api/Controllers/ActivityLogController.cs
  6. 133 0
      Jellyfin.Api/Controllers/AlbumsController.cs
  7. 2 2
      Jellyfin.Api/Controllers/ApiKeyController.cs
  8. 488 0
      Jellyfin.Api/Controllers/ArtistsController.cs
  9. 110 0
      Jellyfin.Api/Controllers/CollectionController.cs
  10. 2 2
      Jellyfin.Api/Controllers/ConfigurationController.cs
  11. 4 8
      Jellyfin.Api/Controllers/DashboardController.cs
  12. 4 4
      Jellyfin.Api/Controllers/DevicesController.cs
  13. 6 6
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  14. 0 3
      Jellyfin.Api/Controllers/FilterController.cs
  15. 6 6
      Jellyfin.Api/Controllers/ImageByNameController.cs
  16. 314 0
      Jellyfin.Api/Controllers/InstantMixController.cs
  17. 1 4
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  18. 1 1
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  19. 1030 0
      Jellyfin.Api/Controllers/LibraryController.cs
  20. 12 14
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  21. 773 0
      Jellyfin.Api/Controllers/MediaInfoController.cs
  22. 340 0
      Jellyfin.Api/Controllers/MoviesController.cs
  23. 6 33
      Jellyfin.Api/Controllers/NotificationsController.cs
  24. 4 4
      Jellyfin.Api/Controllers/PackageController.cs
  25. 7 7
      Jellyfin.Api/Controllers/PlaylistsController.cs
  26. 372 0
      Jellyfin.Api/Controllers/PlaystateController.cs
  27. 3 5
      Jellyfin.Api/Controllers/PluginsController.cs
  28. 1 1
      Jellyfin.Api/Controllers/RemoteImageController.cs
  29. 161 0
      Jellyfin.Api/Controllers/ScheduledTasksController.cs
  30. 5 5
      Jellyfin.Api/Controllers/SearchController.cs
  31. 23 23
      Jellyfin.Api/Controllers/SessionController.cs
  32. 3 3
      Jellyfin.Api/Controllers/StartupController.cs
  33. 277 0
      Jellyfin.Api/Controllers/StudiosController.cs
  34. 7 7
      Jellyfin.Api/Controllers/SubtitleController.cs
  35. 222 0
      Jellyfin.Api/Controllers/SystemController.cs
  36. 377 0
      Jellyfin.Api/Controllers/TvShowsController.cs
  37. 5 8
      Jellyfin.Api/Controllers/UserController.cs
  38. 391 0
      Jellyfin.Api/Controllers/UserLibraryController.cs
  39. 148 0
      Jellyfin.Api/Controllers/UserViewsController.cs
  40. 1 1
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  41. 1 1
      Jellyfin.Api/Controllers/VideosController.cs
  42. 231 0
      Jellyfin.Api/Controllers/YearsController.cs
  43. 25 30
      Jellyfin.Api/Helpers/RequestHelpers.cs
  44. 182 0
      Jellyfin.Api/Helpers/SimilarItemsHelper.cs
  45. 354 0
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  46. 18 0
      Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs
  47. 34 0
      Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs
  48. 41 0
      Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs
  49. 19 0
      Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs
  50. 256 0
      Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs
  51. 212 0
      Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs
  52. 18 0
      Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs
  53. 10 1
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  54. 0 1116
      MediaBrowser.Api/Library/LibraryService.cs
  55. 4 0
      MediaBrowser.Api/MediaBrowser.Api.csproj
  56. 0 105
      MediaBrowser.Api/Movies/CollectionService.cs
  57. 0 322
      MediaBrowser.Api/Movies/MoviesService.cs
  58. 0 132
      MediaBrowser.Api/Music/AlbumsService.cs
  59. 0 197
      MediaBrowser.Api/Music/InstantMixService.cs
  60. 0 5
      MediaBrowser.Api/Playback/MediaInfoService.cs
  61. 0 234
      MediaBrowser.Api/ScheduledTasks/ScheduledTaskService.cs
  62. 0 226
      MediaBrowser.Api/System/SystemService.cs
  63. 0 498
      MediaBrowser.Api/TvShowsService.cs
  64. 0 143
      MediaBrowser.Api/UserLibrary/ArtistsService.cs
  65. 0 456
      MediaBrowser.Api/UserLibrary/PlaystateService.cs
  66. 0 132
      MediaBrowser.Api/UserLibrary/StudiosService.cs
  67. 0 575
      MediaBrowser.Api/UserLibrary/UserLibraryService.cs
  68. 0 146
      MediaBrowser.Api/UserLibrary/UserViewsService.cs
  69. 0 131
      MediaBrowser.Api/UserLibrary/YearsService.cs
  70. 56 0
      MediaBrowser.Common/Json/Converters/JsonInt64Converter.cs
  71. 1 0
      MediaBrowser.Common/Json/JsonDefaults.cs

+ 10 - 1
Jellyfin.Api/Auth/BaseAuthorizationHandler.cs

@@ -42,11 +42,13 @@ namespace Jellyfin.Api.Auth
         /// <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>
+        /// <param name="requiredDownloadPermission">Whether validation requires download permission.</param>
         /// <returns>Validated claim status.</returns>
         protected bool ValidateClaims(
             ClaimsPrincipal claimsPrincipal,
             bool ignoreSchedule = false,
-            bool localAccessOnly = false)
+            bool localAccessOnly = false,
+            bool requiredDownloadPermission = false)
         {
             // Ensure claim has userId.
             var userId = ClaimHelpers.GetUserId(claimsPrincipal);
@@ -89,6 +91,13 @@ namespace Jellyfin.Api.Auth
                 return false;
             }
 
+            // User attempting to download without permission.
+            if (requiredDownloadPermission
+                && !user.HasPermission(PermissionKind.EnableContentDownloading))
+            {
+                return false;
+            }
+
             return true;
         }
 

+ 45 - 0
Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs

@@ -0,0 +1,45 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.DownloadPolicy
+{
+    /// <summary>
+    /// Download authorization handler.
+    /// </summary>
+    public class DownloadHandler : BaseAuthorizationHandler<DownloadRequirement>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DownloadHandler"/> 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 DownloadHandler(
+            IUserManager userManager,
+            INetworkManager networkManager,
+            IHttpContextAccessor httpContextAccessor)
+            : base(userManager, networkManager, httpContextAccessor)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DownloadRequirement requirement)
+        {
+            var validated = ValidateClaims(context.User);
+            if (validated)
+            {
+                context.Succeed(requirement);
+            }
+            else
+            {
+                context.Fail();
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 11 - 0
Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs

@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.DownloadPolicy
+{
+    /// <summary>
+    /// The download permission requirement.
+    /// </summary>
+    public class DownloadRequirement : IAuthorizationRequirement
+    {
+    }
+}

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

@@ -29,5 +29,10 @@ namespace Jellyfin.Api.Constants
         /// Policy name for escaping schedule controls.
         /// </summary>
         public const string IgnoreSchedule = "IgnoreSchedule";
+
+        /// <summary>
+        /// Policy name for requiring download permission.
+        /// </summary>
+        public const string Download = "Download";
     }
 }

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

@@ -35,17 +35,14 @@ namespace Jellyfin.Api.Controllers
         /// <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="minDate">Optional. The minimum date. Format = ISO.</param>
-        /// <param name="hasUserId">Optional. Only returns activities that have a user associated.</param>
         /// <response code="200">Activity log returned.</response>
         /// <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,
-            [FromQuery] DateTime? minDate,
-            bool? hasUserId)
+            [FromQuery] DateTime? minDate)
         {
             var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>(
                 entries => entries.Where(entry => entry.DateCreated >= minDate));

+ 133 - 0
Jellyfin.Api/Controllers/AlbumsController.cs

@@ -0,0 +1,133 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The albums controller.
+    /// </summary>
+    public class AlbumsController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IDtoService _dtoService;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AlbumsController"/> 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>
+        public AlbumsController(
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IDtoService dtoService)
+        {
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+        }
+
+        /// <summary>
+        /// Finds albums similar to a given album.
+        /// </summary>
+        /// <param name="albumId">The album id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <response code="200">Similar albums returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar albums.</returns>
+        [HttpGet("/Albums/{albumId}/Similar")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums(
+            [FromRoute] string albumId,
+            [FromQuery] Guid userId,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] int? limit)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return SimilarItemsHelper.GetSimilarItemsResult(
+                dtoOptions,
+                _userManager,
+                _libraryManager,
+                _dtoService,
+                userId,
+                albumId,
+                excludeArtistIds,
+                limit,
+                new[] { typeof(MusicAlbum) },
+                GetAlbumSimilarityScore);
+        }
+
+        /// <summary>
+        /// Finds artists similar to a given artist.
+        /// </summary>
+        /// <param name="artistId">The artist id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <response code="200">Similar artists returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar artists.</returns>
+        [HttpGet("/Artists/{artistId}/Similar")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists(
+            [FromRoute] string artistId,
+            [FromQuery] Guid userId,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] int? limit)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return SimilarItemsHelper.GetSimilarItemsResult(
+                dtoOptions,
+                _userManager,
+                _libraryManager,
+                _dtoService,
+                userId,
+                artistId,
+                excludeArtistIds,
+                limit,
+                new[] { typeof(MusicArtist) },
+                SimilarItemsHelper.GetSimiliarityScore);
+        }
+
+        /// <summary>
+        /// Gets a similairty score of two albums.
+        /// </summary>
+        /// <param name="item1">The first item.</param>
+        /// <param name="item1People">The item1 people.</param>
+        /// <param name="allPeople">All people.</param>
+        /// <param name="item2">The second item.</param>
+        /// <returns>System.Int32.</returns>
+        private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2)
+        {
+            var points = SimilarItemsHelper.GetSimiliarityScore(item1, item1People, allPeople, item2);
+
+            var album1 = (MusicAlbum)item1;
+            var album2 = (MusicAlbum)item2;
+
+            var artists1 = album1
+                .GetAllArtists()
+                .DistinctNames()
+                .ToList();
+
+            var artists2 = new HashSet<string>(
+                album2.GetAllArtists().DistinctNames(),
+                StringComparer.OrdinalIgnoreCase);
+
+            return points + artists1.Where(artists2.Contains).Sum(i => 5);
+        }
+    }
+}

+ 2 - 2
Jellyfin.Api/Controllers/ApiKeyController.cs

@@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Keys")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult CreateKey([FromQuery, Required] string app)
+        public ActionResult CreateKey([FromQuery, Required] string? app)
         {
             _authRepo.Create(new AuthenticationInfo
             {
@@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers
         [HttpDelete("Keys/{key}")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult RevokeKey([FromRoute] string key)
+        public ActionResult RevokeKey([FromRoute] string? key)
         {
             _sessionManager.RevokeToken(key);
             return NoContent();

+ 488 - 0
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -0,0 +1,488 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+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>
+    /// The artists controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Route("/Artists")]
+    public class ArtistsController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
+        private readonly IDtoService _dtoService;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ArtistsController"/> 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 ArtistsController(
+            ILibraryManager libraryManager,
+            IUserManager userManager,
+            IDtoService dtoService)
+        {
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dtoService = dtoService;
+        }
+
+        /// <summary>
+        /// Gets all artists from a given item, folder, or the entire library.
+        /// </summary>
+        /// <param name="minCommunityRating">Optional filter by minimum community rating.</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="searchTerm">Optional. Search term.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</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>
+        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
+        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+        /// <param name="userId">User id.</param>
+        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+        /// <param name="enableImages">Optional, include image information in output.</param>
+        /// <param name="enableTotalRecordCount">Total record count.</param>
+        /// <response code="200">Artists returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the artists.</returns>
+        [HttpGet]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetArtists(
+            [FromQuery] double? minCommunityRating,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string searchTerm,
+            [FromQuery] string parentId,
+            [FromQuery] string fields,
+            [FromQuery] string excludeItemTypes,
+            [FromQuery] string includeItemTypes,
+            [FromQuery] string filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string mediaTypes,
+            [FromQuery] string genres,
+            [FromQuery] string genreIds,
+            [FromQuery] string officialRatings,
+            [FromQuery] string tags,
+            [FromQuery] string years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string enableImageTypes,
+            [FromQuery] string person,
+            [FromQuery] string personIds,
+            [FromQuery] string personTypes,
+            [FromQuery] string studios,
+            [FromQuery] string studioIds,
+            [FromQuery] Guid userId,
+            [FromQuery] string nameStartsWithOrGreater,
+            [FromQuery] string nameStartsWith,
+            [FromQuery] string nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (!userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, ',', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
+                Genres = RequestHelpers.Split(genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|').Select(i =>
+                {
+                    try
+                    {
+                        return _libraryManager.GetStudio(i);
+                    }
+                    catch
+                    {
+                        return null;
+                    }
+                }).Where(i => i != null).Select(i => i!.Id).ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(filters))
+            {
+                switch (filter)
+                {
+                    case ItemFilter.Dislikes:
+                        query.IsLiked = false;
+                        break;
+                    case ItemFilter.IsFavorite:
+                        query.IsFavorite = true;
+                        break;
+                    case ItemFilter.IsFavoriteOrLikes:
+                        query.IsFavoriteOrLiked = true;
+                        break;
+                    case ItemFilter.IsFolder:
+                        query.IsFolder = true;
+                        break;
+                    case ItemFilter.IsNotFolder:
+                        query.IsFolder = false;
+                        break;
+                    case ItemFilter.IsPlayed:
+                        query.IsPlayed = true;
+                        break;
+                    case ItemFilter.IsResumable:
+                        query.IsResumable = true;
+                        break;
+                    case ItemFilter.IsUnplayed:
+                        query.IsPlayed = false;
+                        break;
+                    case ItemFilter.Likes:
+                        query.IsLiked = true;
+                        break;
+                }
+            }
+
+            var result = _libraryManager.GetArtists(query);
+
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, itemCounts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                {
+                    dto.ChildCount = itemCounts.ItemCount;
+                    dto.ProgramCount = itemCounts.ProgramCount;
+                    dto.SeriesCount = itemCounts.SeriesCount;
+                    dto.EpisodeCount = itemCounts.EpisodeCount;
+                    dto.MovieCount = itemCounts.MovieCount;
+                    dto.TrailerCount = itemCounts.TrailerCount;
+                    dto.AlbumCount = itemCounts.AlbumCount;
+                    dto.SongCount = itemCounts.SongCount;
+                    dto.ArtistCount = itemCounts.ArtistCount;
+                }
+
+                return dto;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <summary>
+        /// Gets all album artists from a given item, folder, or the entire library.
+        /// </summary>
+        /// <param name="minCommunityRating">Optional filter by minimum community rating.</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="searchTerm">Optional. Search term.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</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>
+        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
+        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+        /// <param name="userId">User id.</param>
+        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+        /// <param name="enableImages">Optional, include image information in output.</param>
+        /// <param name="enableTotalRecordCount">Total record count.</param>
+        /// <response code="200">Album artists returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
+        [HttpGet("AlbumArtists")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
+            [FromQuery] double? minCommunityRating,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string searchTerm,
+            [FromQuery] string parentId,
+            [FromQuery] string fields,
+            [FromQuery] string excludeItemTypes,
+            [FromQuery] string includeItemTypes,
+            [FromQuery] string filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string mediaTypes,
+            [FromQuery] string genres,
+            [FromQuery] string genreIds,
+            [FromQuery] string officialRatings,
+            [FromQuery] string tags,
+            [FromQuery] string years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string enableImageTypes,
+            [FromQuery] string person,
+            [FromQuery] string personIds,
+            [FromQuery] string personTypes,
+            [FromQuery] string studios,
+            [FromQuery] string studioIds,
+            [FromQuery] Guid userId,
+            [FromQuery] string nameStartsWithOrGreater,
+            [FromQuery] string nameStartsWith,
+            [FromQuery] string nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (!userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, ',', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
+                Genres = RequestHelpers.Split(genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|').Select(i =>
+                {
+                    try
+                    {
+                        return _libraryManager.GetStudio(i);
+                    }
+                    catch
+                    {
+                        return null;
+                    }
+                }).Where(i => i != null).Select(i => i!.Id).ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(filters))
+            {
+                switch (filter)
+                {
+                    case ItemFilter.Dislikes:
+                        query.IsLiked = false;
+                        break;
+                    case ItemFilter.IsFavorite:
+                        query.IsFavorite = true;
+                        break;
+                    case ItemFilter.IsFavoriteOrLikes:
+                        query.IsFavoriteOrLiked = true;
+                        break;
+                    case ItemFilter.IsFolder:
+                        query.IsFolder = true;
+                        break;
+                    case ItemFilter.IsNotFolder:
+                        query.IsFolder = false;
+                        break;
+                    case ItemFilter.IsPlayed:
+                        query.IsPlayed = true;
+                        break;
+                    case ItemFilter.IsResumable:
+                        query.IsResumable = true;
+                        break;
+                    case ItemFilter.IsUnplayed:
+                        query.IsPlayed = false;
+                        break;
+                    case ItemFilter.Likes:
+                        query.IsLiked = true;
+                        break;
+                }
+            }
+
+            var result = _libraryManager.GetAlbumArtists(query);
+
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, itemCounts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                {
+                    dto.ChildCount = itemCounts.ItemCount;
+                    dto.ProgramCount = itemCounts.ProgramCount;
+                    dto.SeriesCount = itemCounts.SeriesCount;
+                    dto.EpisodeCount = itemCounts.EpisodeCount;
+                    dto.MovieCount = itemCounts.MovieCount;
+                    dto.TrailerCount = itemCounts.TrailerCount;
+                    dto.AlbumCount = itemCounts.AlbumCount;
+                    dto.SongCount = itemCounts.SongCount;
+                    dto.ArtistCount = itemCounts.ArtistCount;
+                }
+
+                return dto;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <summary>
+        /// Gets an artist by name.
+        /// </summary>
+        /// <param name="name">Studio name.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <response code="200">Artist returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the artist.</returns>
+        [HttpGet("{name}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<BaseItemDto> GetArtistByName([FromRoute] string name, [FromQuery] Guid userId)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            var item = _libraryManager.GetArtist(name, dtoOptions);
+
+            if (!userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId);
+
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+    }
+}

+ 110 - 0
Jellyfin.Api/Controllers/CollectionController.cs

@@ -0,0 +1,110 @@
+using System;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Collections;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The collection controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    [Route("/Collections")]
+    public class CollectionController : BaseJellyfinApiController
+    {
+        private readonly ICollectionManager _collectionManager;
+        private readonly IDtoService _dtoService;
+        private readonly IAuthorizationContext _authContext;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="CollectionController"/> class.
+        /// </summary>
+        /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param>
+        /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+        /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+        public CollectionController(
+            ICollectionManager collectionManager,
+            IDtoService dtoService,
+            IAuthorizationContext authContext)
+        {
+            _collectionManager = collectionManager;
+            _dtoService = dtoService;
+            _authContext = authContext;
+        }
+
+        /// <summary>
+        /// Creates a new collection.
+        /// </summary>
+        /// <param name="name">The name of the collection.</param>
+        /// <param name="ids">Item Ids to add to the collection.</param>
+        /// <param name="isLocked">Whether or not to lock the new collection.</param>
+        /// <param name="parentId">Optional. Create the collection within a specific folder.</param>
+        /// <response code="200">Collection created.</response>
+        /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
+        [HttpPost]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<CollectionCreationResult> CreateCollection(
+            [FromQuery] string? name,
+            [FromQuery] string? ids,
+            [FromQuery] bool isLocked,
+            [FromQuery] Guid? parentId)
+        {
+            var userId = _authContext.GetAuthorizationInfo(Request).UserId;
+
+            var item = _collectionManager.CreateCollection(new CollectionCreationOptions
+            {
+                IsLocked = isLocked,
+                Name = name,
+                ParentId = parentId,
+                ItemIdList = RequestHelpers.Split(ids, ',', true),
+                UserIds = new[] { userId }
+            });
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
+
+            return new CollectionCreationResult
+            {
+                Id = dto.Id
+            };
+        }
+
+        /// <summary>
+        /// Adds items to a collection.
+        /// </summary>
+        /// <param name="collectionId">The collection id.</param>
+        /// <param name="itemIds">Item ids, comma delimited.</param>
+        /// <response code="204">Items added to collection.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("{collectionId}/Items")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery] string? itemIds)
+        {
+            _collectionManager.AddToCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Removes items from a collection.
+        /// </summary>
+        /// <param name="collectionId">The collection id.</param>
+        /// <param name="itemIds">Item ids, comma delimited.</param>
+        /// <response code="204">Items removed from collection.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpDelete("{collectionId}/Items")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery] string? itemIds)
+        {
+            _collectionManager.RemoveFromCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
+            return NoContent();
+        }
+    }
+}

+ 2 - 2
Jellyfin.Api/Controllers/ConfigurationController.cs

@@ -70,7 +70,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>Configuration.</returns>
         [HttpGet("Configuration/{key}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<object> GetNamedConfiguration([FromRoute] string key)
+        public ActionResult<object> GetNamedConfiguration([FromRoute] string? key)
         {
             return _configurationManager.GetConfiguration(key);
         }
@@ -84,7 +84,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Configuration/{key}")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key)
+        public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string? key)
         {
             var configurationType = _configurationManager.GetConfigurationType(key);
             var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false);

+ 4 - 8
Jellyfin.Api/Controllers/DashboardController.cs

@@ -122,7 +122,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("/web/ConfigurationPage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult GetDashboardConfigurationPage([FromQuery] string name)
+        public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
         {
             IPlugin? plugin = null;
             Stream? stream = null;
@@ -178,14 +178,13 @@ namespace Jellyfin.Api.Controllers
         [ApiExplorerSettings(IgnoreApi = true)]
         public ActionResult GetRobotsTxt()
         {
-            return GetWebClientResource("robots.txt", string.Empty);
+            return GetWebClientResource("robots.txt");
         }
 
         /// <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>
@@ -193,10 +192,7 @@ namespace Jellyfin.Api.Controllers
         [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)
+        public ActionResult GetWebClientResource([FromRoute] string resourceName)
         {
             if (!_appConfig.HostWebClient() || WebClientUiPath == null)
             {
@@ -228,7 +224,7 @@ namespace Jellyfin.Api.Controllers
         [ApiExplorerSettings(IgnoreApi = true)]
         public ActionResult GetFavIcon()
         {
-            return GetWebClientResource("favicon.ico", string.Empty);
+            return GetWebClientResource("favicon.ico");
         }
 
         /// <summary>

+ 4 - 4
Jellyfin.Api/Controllers/DevicesController.cs

@@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string id)
+        public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string? id)
         {
             var deviceInfo = _deviceManager.GetDevice(id);
             if (deviceInfo == null)
@@ -87,7 +87,7 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string id)
+        public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string? id)
         {
             var deviceInfo = _deviceManager.GetDeviceOptions(id);
             if (deviceInfo == null)
@@ -111,7 +111,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult UpdateDeviceOptions(
-            [FromQuery, BindRequired] string id,
+            [FromQuery, BindRequired] string? id,
             [FromBody, BindRequired] DeviceOptions deviceOptions)
         {
             var existingDeviceOptions = _deviceManager.GetDeviceOptions(id);
@@ -134,7 +134,7 @@ namespace Jellyfin.Api.Controllers
         [HttpDelete]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult DeleteDevice([FromQuery, BindRequired] string id)
+        public ActionResult DeleteDevice([FromQuery, BindRequired] string? id)
         {
             var existingDevice = _deviceManager.GetDevice(id);
             if (existingDevice == null)

+ 6 - 6
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -39,9 +39,9 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("{displayPreferencesId}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<DisplayPreferences> GetDisplayPreferences(
-            [FromRoute] string displayPreferencesId,
-            [FromQuery] [Required] string userId,
-            [FromQuery] [Required] string client)
+            [FromRoute] string? displayPreferencesId,
+            [FromQuery] [Required] string? userId,
+            [FromQuery] [Required] string? client)
         {
             return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client);
         }
@@ -59,9 +59,9 @@ namespace Jellyfin.Api.Controllers
         [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,
+            [FromRoute] string? displayPreferencesId,
+            [FromQuery, BindRequired] string? userId,
+            [FromQuery, BindRequired] string? client,
             [FromBody, BindRequired] DisplayPreferences displayPreferences)
         {
             _displayPreferencesRepository.SaveDisplayPreferences(

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

@@ -125,7 +125,6 @@ namespace Jellyfin.Api.Controllers
         /// <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>
@@ -137,12 +136,10 @@ namespace Jellyfin.Api.Controllers
         /// <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,

+ 6 - 6
Jellyfin.Api/Controllers/ImageByNameController.cs

@@ -64,7 +64,7 @@ namespace Jellyfin.Api.Controllers
         [Produces(MediaTypeNames.Application.Octet)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string name, [FromRoute] string type)
+        public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string? name, [FromRoute] string? type)
         {
             var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase)
                 ? "folder"
@@ -110,8 +110,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult<FileStreamResult> GetRatingImage(
-            [FromRoute] string theme,
-            [FromRoute] string name)
+            [FromRoute] string? theme,
+            [FromRoute] string? name)
         {
             return GetImageFile(_applicationPaths.RatingsPath, theme, name);
         }
@@ -143,8 +143,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult<FileStreamResult> GetMediaInfoImage(
-            [FromRoute] string theme,
-            [FromRoute] string name)
+            [FromRoute] string? theme,
+            [FromRoute] string? name)
         {
             return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name);
         }
@@ -156,7 +156,7 @@ namespace Jellyfin.Api.Controllers
         /// <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)
+        private ActionResult<FileStreamResult> GetImageFile(string basePath, string? theme, string? name)
         {
             var themeFolder = Path.Combine(basePath, theme);
             if (Directory.Exists(themeFolder))

+ 314 - 0
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -0,0 +1,314 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+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>
+    /// The instant mix controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class InstantMixController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly IDtoService _dtoService;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IMusicManager _musicManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="InstantMixController"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+        /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        public InstantMixController(
+            IUserManager userManager,
+            IDtoService dtoService,
+            IMusicManager musicManager,
+            ILibraryManager libraryManager)
+        {
+            _userManager = userManager;
+            _dtoService = dtoService;
+            _musicManager = musicManager;
+            _libraryManager = libraryManager;
+        }
+
+        /// <summary>
+        /// Creates an instant playlist based on a given song.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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="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">Instant playlist returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+        [HttpGet("/Songs/{id}/InstantMix")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
+            [FromRoute] Guid id,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+            return GetResult(items, user, limit, dtoOptions);
+        }
+
+        /// <summary>
+        /// Creates an instant playlist based on a given song.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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="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">Instant playlist returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+        [HttpGet("/Albums/{id}/InstantMix")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
+            [FromRoute] Guid id,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var album = _libraryManager.GetItemById(id);
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+            var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
+            return GetResult(items, user, limit, dtoOptions);
+        }
+
+        /// <summary>
+        /// Creates an instant playlist based on a given song.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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="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">Instant playlist returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+        [HttpGet("/Playlists/{id}/InstantMix")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
+            [FromRoute] Guid id,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var playlist = (Playlist)_libraryManager.GetItemById(id);
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+            var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
+            return GetResult(items, user, limit, dtoOptions);
+        }
+
+        /// <summary>
+        /// Creates an instant playlist based on a given song.
+        /// </summary>
+        /// <param name="name">The genre name.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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="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">Instant playlist returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+        [HttpGet("/MusicGenres/{name}/InstantMix")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenre(
+            [FromRoute] string? name,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+            var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
+            return GetResult(items, user, limit, dtoOptions);
+        }
+
+        /// <summary>
+        /// Creates an instant playlist based on a given song.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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="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">Instant playlist returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+        [HttpGet("/Artists/InstantMix")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
+            [FromRoute] Guid id,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+            return GetResult(items, user, limit, dtoOptions);
+        }
+
+        /// <summary>
+        /// Creates an instant playlist based on a given song.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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="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">Instant playlist returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+        [HttpGet("/MusicGenres/InstantMix")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
+            [FromRoute] Guid id,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+            return GetResult(items, user, limit, dtoOptions);
+        }
+
+        /// <summary>
+        /// Creates an instant playlist based on a given song.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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="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">Instant playlist returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+        [HttpGet("/Items/{id}/InstantMix")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
+            [FromRoute] Guid id,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes)
+        {
+            var item = _libraryManager.GetItemById(id);
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+            return GetResult(items, user, limit, dtoOptions);
+        }
+
+        private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User user, int? limit, DtoOptions dtoOptions)
+        {
+            var list = items;
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = list.Count
+            };
+
+            if (limit.HasValue)
+            {
+                list = list.Take(limit.Value).ToList();
+            }
+
+            var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
+
+            result.Items = returnList;
+
+            return result;
+        }
+    }
+}

+ 1 - 4
Jellyfin.Api/Controllers/ItemRefreshController.cs

@@ -47,7 +47,6 @@ namespace Jellyfin.Api.Controllers
         /// <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>
@@ -55,14 +54,12 @@ namespace Jellyfin.Api.Controllers
         [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)
+            [FromQuery] bool replaceAllImages = false)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)

+ 1 - 1
Jellyfin.Api/Controllers/ItemUpdateController.cs

@@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Items/{itemId}/ContentType")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string contentType)
+        public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string? contentType)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)

+ 1030 - 0
Jellyfin.Api/Controllers/LibraryController.cs

@@ -0,0 +1,1030 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.LibraryDtos;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Progress;
+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.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+using Book = MediaBrowser.Controller.Entities.Book;
+using Movie = Jellyfin.Data.Entities.Movie;
+using MusicAlbum = Jellyfin.Data.Entities.MusicAlbum;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Library Controller.
+    /// </summary>
+    public class LibraryController : BaseJellyfinApiController
+    {
+        private readonly IProviderManager _providerManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
+        private readonly IDtoService _dtoService;
+        private readonly IAuthorizationContext _authContext;
+        private readonly IActivityManager _activityManager;
+        private readonly ILocalizationManager _localization;
+        private readonly ILibraryMonitor _libraryMonitor;
+        private readonly ILogger<LibraryController> _logger;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LibraryController"/> class.
+        /// </summary>
+        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+        /// <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>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
+        /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+        /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public LibraryController(
+            IProviderManager providerManager,
+            ILibraryManager libraryManager,
+            IUserManager userManager,
+            IDtoService dtoService,
+            IAuthorizationContext authContext,
+            IActivityManager activityManager,
+            ILocalizationManager localization,
+            ILibraryMonitor libraryMonitor,
+            ILogger<LibraryController> logger,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _providerManager = providerManager;
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dtoService = dtoService;
+            _authContext = authContext;
+            _activityManager = activityManager;
+            _localization = localization;
+            _libraryMonitor = libraryMonitor;
+            _logger = logger;
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <summary>
+        /// Get the original file of an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <response code="200">File stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns>
+        [HttpGet("/Items/{itemId}/File")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult GetFile([FromRoute] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            using var fileStream = new FileStream(item.Path, FileMode.Open, FileAccess.Read);
+            return File(fileStream, MimeTypes.GetMimeType(item.Path));
+        }
+
+        /// <summary>
+        /// Gets critic review for an item.
+        /// </summary>
+        /// <response code="200">Critic reviews returned.</response>
+        /// <returns>The list of critic reviews.</returns>
+        [HttpGet("/Items/{itemId}/CriticReviews")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [Obsolete("This endpoint is obsolete.")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews()
+        {
+            return new QueryResult<BaseItemDto>();
+        }
+
+        /// <summary>
+        /// Get theme songs for an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+        /// <response code="200">Theme songs returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>The item theme songs.</returns>
+        [HttpGet("/Items/{itemId}/ThemeSongs")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<ThemeMediaResult> GetThemeSongs(
+            [FromRoute] Guid itemId,
+            [FromQuery] Guid userId,
+            [FromQuery] bool inheritFromParent)
+        {
+            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);
+
+            if (item == null)
+            {
+                return NotFound("Item not found.");
+            }
+
+            IEnumerable<BaseItem> themeItems;
+
+            while (true)
+            {
+                themeItems = item.GetThemeSongs();
+
+                if (themeItems.Any() || !inheritFromParent)
+                {
+                    break;
+                }
+
+                var parent = item.GetParent();
+                if (parent == null)
+                {
+                    break;
+                }
+
+                item = parent;
+            }
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var items = themeItems
+                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
+                .ToArray();
+
+            return new ThemeMediaResult
+            {
+                Items = items,
+                TotalRecordCount = items.Length,
+                OwnerId = item.Id
+            };
+        }
+
+        /// <summary>
+        /// Get theme videos for an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+        /// <response code="200">Theme videos returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>The item theme videos.</returns>
+        [HttpGet("/Items/{itemId}/ThemeVideos")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<ThemeMediaResult> GetThemeVideos(
+            [FromRoute] Guid itemId,
+            [FromQuery] Guid userId,
+            [FromQuery] bool inheritFromParent)
+        {
+            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);
+
+            if (item == null)
+            {
+                return NotFound("Item not found.");
+            }
+
+            IEnumerable<BaseItem> themeItems;
+
+            while (true)
+            {
+                themeItems = item.GetThemeVideos();
+
+                if (themeItems.Any() || !inheritFromParent)
+                {
+                    break;
+                }
+
+                var parent = item.GetParent();
+                if (parent == null)
+                {
+                    break;
+                }
+
+                item = parent;
+            }
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var items = themeItems
+                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
+                .ToArray();
+
+            return new ThemeMediaResult
+            {
+                Items = items,
+                TotalRecordCount = items.Length,
+                OwnerId = item.Id
+            };
+        }
+
+        /// <summary>
+        /// Get theme songs and videos for an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+        /// <response code="200">Theme songs and videos returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>The item theme videos.</returns>
+        [HttpGet("/Items/{itemId}/ThemeMedia")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<AllThemeMediaResult> GetThemeMedia(
+            [FromRoute] Guid itemId,
+            [FromQuery] Guid userId,
+            [FromQuery] bool inheritFromParent)
+        {
+            var themeSongs = GetThemeSongs(
+                itemId,
+                userId,
+                inheritFromParent);
+
+            var themeVideos = GetThemeVideos(
+                itemId,
+                userId,
+                inheritFromParent);
+
+            return new AllThemeMediaResult
+            {
+                ThemeSongsResult = themeSongs?.Value,
+                ThemeVideosResult = themeVideos?.Value,
+                SoundtrackSongsResult = new ThemeMediaResult()
+            };
+        }
+
+        /// <summary>
+        /// Starts a library scan.
+        /// </summary>
+        /// <response code="204">Library scan started.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpGet("/Library/Refresh")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> RefreshLibrary()
+        {
+            try
+            {
+                await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+            }
+            catch (Exception ex)
+            {
+                 _logger.LogError(ex, "Error refreshing library");
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Deletes an item from the library and filesystem.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <response code="204">Item deleted.</response>
+        /// <response code="401">Unauthorized access.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("/Items/{itemId}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+        public ActionResult DeleteItem(Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            var auth = _authContext.GetAuthorizationInfo(Request);
+            var user = auth.User;
+
+            if (!item.CanDelete(user))
+            {
+                return Unauthorized("Unauthorized access");
+            }
+
+            _libraryManager.DeleteItem(
+                item,
+                new DeleteOptions { DeleteFileLocation = true },
+                true);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Deletes items from the library and filesystem.
+        /// </summary>
+        /// <param name="ids">The item ids.</param>
+        /// <response code="204">Items deleted.</response>
+        /// <response code="401">Unauthorized access.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("/Items")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+        public ActionResult DeleteItems([FromQuery] string ids)
+        {
+            var itemIds = string.IsNullOrWhiteSpace(ids)
+                ? Array.Empty<string>()
+                : RequestHelpers.Split(ids, ',', true);
+
+            foreach (var i in itemIds)
+            {
+                var item = _libraryManager.GetItemById(i);
+                var auth = _authContext.GetAuthorizationInfo(Request);
+                var user = auth.User;
+
+                if (!item.CanDelete(user))
+                {
+                    if (ids.Length > 1)
+                    {
+                        return Unauthorized("Unauthorized access");
+                    }
+
+                    continue;
+                }
+
+                _libraryManager.DeleteItem(
+                    item,
+                    new DeleteOptions { DeleteFileLocation = true },
+                    true);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Get item counts.
+        /// </summary>
+        /// <param name="userId">Optional. Get counts from a specific user's library.</param>
+        /// <param name="isFavorite">Optional. Get counts of favorite items.</param>
+        /// <response code="200">Item counts returned.</response>
+        /// <returns>Item counts.</returns>
+        [HttpGet("/Items/Counts")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<ItemCounts> GetItemCounts(
+            [FromQuery] Guid userId,
+            [FromQuery] bool? isFavorite)
+        {
+            var user = userId.Equals(Guid.Empty)
+                ? null
+                : _userManager.GetUserById(userId);
+
+            var counts = new ItemCounts
+            {
+                AlbumCount = GetCount(typeof(MusicAlbum), user, isFavorite),
+                EpisodeCount = GetCount(typeof(Episode), user, isFavorite),
+                MovieCount = GetCount(typeof(Movie), user, isFavorite),
+                SeriesCount = GetCount(typeof(Series), user, isFavorite),
+                SongCount = GetCount(typeof(Audio), user, isFavorite),
+                MusicVideoCount = GetCount(typeof(MusicVideo), user, isFavorite),
+                BoxSetCount = GetCount(typeof(BoxSet), user, isFavorite),
+                BookCount = GetCount(typeof(Book), user, isFavorite)
+            };
+
+            return counts;
+        }
+
+        /// <summary>
+        /// Gets all parents of an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <response code="200">Item parents returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>Item parents.</returns>
+        [HttpGet("/Items/{itemId}/Ancestors")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute] Guid itemId, [FromQuery] Guid userId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            if (item == null)
+            {
+                return NotFound("Item not found");
+            }
+
+            var baseItemDtos = new List<BaseItemDto>();
+
+            var user = !userId.Equals(Guid.Empty)
+                ? _userManager.GetUserById(userId)
+                : null;
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            BaseItem parent = item.GetParent();
+
+            while (parent != null)
+            {
+                if (user != null)
+                {
+                    parent = TranslateParentItem(parent, user);
+                }
+
+                baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
+
+                parent = parent.GetParent();
+            }
+
+            return baseItemDtos;
+        }
+
+        /// <summary>
+        /// Gets a list of physical paths from virtual folders.
+        /// </summary>
+        /// <response code="200">Physical paths returned.</response>
+        /// <returns>List of physical paths.</returns>
+        [HttpGet("/Library/PhysicalPaths")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<string>> GetPhysicalPaths()
+        {
+            return Ok(_libraryManager.RootFolder.Children
+                .SelectMany(c => c.PhysicalLocations));
+        }
+
+        /// <summary>
+        /// Gets all user media folders.
+        /// </summary>
+        /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param>
+        /// <response code="200">Media folders returned.</response>
+        /// <returns>List of user media folders.</returns>
+        [HttpGet("/Library/MediaFolders")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden)
+        {
+            var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList();
+
+            if (isHidden.HasValue)
+            {
+                var val = isHidden.Value;
+
+                items = items.Where(i => i.IsHidden == val).ToList();
+            }
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var result = new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = items.Count,
+                Items = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions)).ToArray()
+            };
+
+            return result;
+        }
+
+        /// <summary>
+        /// Reports that new episodes of a series have been added by an external source.
+        /// </summary>
+        /// <param name="tvdbId">The tvdbId.</param>
+        /// <response code="204">Report success.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Library/Series/Added")]
+        [HttpPost("/Library/Series/Updated")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId)
+        {
+            var series = _libraryManager.GetItemList(new InternalItemsQuery
+            {
+                IncludeItemTypes = new[] { nameof(Series) },
+                DtoOptions = new DtoOptions(false)
+                {
+                    EnableImages = false
+                }
+            }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray();
+
+            foreach (var item in series)
+            {
+                _libraryMonitor.ReportFileSystemChanged(item.Path);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that new movies have been added by an external source.
+        /// </summary>
+        /// <param name="tmdbId">The tmdbId.</param>
+        /// <param name="imdbId">The imdbId.</param>
+        /// <response code="204">Report success.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Library/Movies/Added")]
+        [HttpPost("/Library/Movies/Updated")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult PostUpdatedMovies([FromRoute] string? tmdbId, [FromRoute] string? imdbId)
+        {
+            var movies = _libraryManager.GetItemList(new InternalItemsQuery
+            {
+                IncludeItemTypes = new[] { nameof(Movie) },
+                DtoOptions = new DtoOptions(false)
+                {
+                    EnableImages = false
+                }
+            });
+
+            if (!string.IsNullOrWhiteSpace(imdbId))
+            {
+                movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList();
+            }
+            else if (!string.IsNullOrWhiteSpace(tmdbId))
+            {
+                movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList();
+            }
+            else
+            {
+                movies = new List<BaseItem>();
+            }
+
+            foreach (var item in movies)
+            {
+                _libraryMonitor.ReportFileSystemChanged(item.Path);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that new movies have been added by an external source.
+        /// </summary>
+        /// <param name="updates">A list of updated media paths.</param>
+        /// <response code="204">Report success.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Library/Media/Updated")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult PostUpdatedMedia([FromBody, BindRequired] MediaUpdateInfoDto[] updates)
+        {
+            foreach (var item in updates)
+            {
+                _libraryMonitor.ReportFileSystemChanged(item.Path);
+            }
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Downloads item media.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <response code="200">Media downloaded.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>A <see cref="FileResult"/> containing the media stream.</returns>
+        /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception>
+        [HttpGet("/Items/{itemId}/Download")]
+        [Authorize(Policy = Policies.Download)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult GetDownload([FromRoute] Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var auth = _authContext.GetAuthorizationInfo(Request);
+
+            var user = auth.User;
+
+            if (user != null)
+            {
+                if (!item.CanDownload(user))
+                {
+                    throw new ArgumentException("Item does not support downloading");
+                }
+            }
+            else
+            {
+                if (!item.CanDownload())
+                {
+                    throw new ArgumentException("Item does not support downloading");
+                }
+            }
+
+            if (user != null)
+            {
+                LogDownload(item, user, auth);
+            }
+
+            var path = item.Path;
+
+            // Quotes are valid in linux. They'll possibly cause issues here
+            var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty, StringComparison.Ordinal);
+            if (!string.IsNullOrWhiteSpace(filename))
+            {
+                // Kestrel doesn't support non-ASCII characters in headers
+                if (Regex.IsMatch(filename, @"[^\p{IsBasicLatin}]"))
+                {
+                    // Manually encoding non-ASCII characters, following https://tools.ietf.org/html/rfc5987#section-3.2.2
+                    filename = WebUtility.UrlEncode(filename);
+                }
+            }
+
+            // TODO determine non-ASCII validity.
+            using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read);
+            return File(fileStream, MimeTypes.GetMimeType(path), filename);
+        }
+
+        /// <summary>
+        /// Gets similar items.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="excludeArtistIds">Exclude artist ids.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</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, TrailerUrls.</param>
+        /// <response code="200">Similar items returned.</response>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns>
+        [HttpGet("/Artists/{itemId}/Similar")]
+        [HttpGet("/Items/{itemId}/Similar")]
+        [HttpGet("/Albums/{itemId}/Similar")]
+        [HttpGet("/Shows/{itemId}/Similar")]
+        [HttpGet("/Movies/{itemId}/Similar")]
+        [HttpGet("/Trailers/{itemId}/Similar")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
+            [FromRoute] Guid itemId,
+            [FromQuery] string? excludeArtistIds,
+            [FromQuery] Guid userId,
+            [FromQuery] int? limit,
+            [FromQuery] string? fields)
+        {
+            var item = itemId.Equals(Guid.Empty)
+                ? (!userId.Equals(Guid.Empty)
+                    ? _libraryManager.GetUserRootFolder()
+                    : _libraryManager.RootFolder)
+                : _libraryManager.GetItemById(itemId);
+
+            var program = item as IHasProgramAttributes;
+            var isMovie = item is MediaBrowser.Controller.Entities.Movies.Movie || (program != null && program.IsMovie) || item is Trailer;
+            if (program != null && program.IsSeries)
+            {
+                return GetSimilarItemsResult(
+                    item,
+                    excludeArtistIds,
+                    userId,
+                    limit,
+                    fields,
+                    new[] { nameof(Series) },
+                    false);
+            }
+
+            if (item is MediaBrowser.Controller.Entities.TV.Episode || (item is IItemByName && !(item is MusicArtist)))
+            {
+                return new QueryResult<BaseItemDto>();
+            }
+
+            return GetSimilarItemsResult(
+                item,
+                excludeArtistIds,
+                userId,
+                limit,
+                fields,
+                new[] { item.GetType().Name },
+                isMovie);
+        }
+
+        /// <summary>
+        /// Gets the library options info.
+        /// </summary>
+        /// <param name="libraryContentType">Library content type.</param>
+        /// <param name="isNewLibrary">Whether this is a new library.</param>
+        /// <response code="200">Library options info returned.</response>
+        /// <returns>Library options info.</returns>
+        [HttpGet("/Libraries/AvailableOptions")]
+        [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo([FromQuery] string? libraryContentType, [FromQuery] bool isNewLibrary)
+        {
+            var result = new LibraryOptionsResultDto();
+
+            var types = GetRepresentativeItemTypes(libraryContentType);
+            var typesList = types.ToList();
+
+            var plugins = _providerManager.GetAllMetadataPlugins()
+                .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase))
+                .OrderBy(i => typesList.IndexOf(i.ItemType))
+                .ToList();
+
+            result.MetadataSavers = plugins
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver))
+                .Select(i => new LibraryOptionInfoDto
+                {
+                    Name = i.Name,
+                    DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary)
+                })
+                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(x => x.First())
+                .ToArray();
+
+            result.MetadataReaders = plugins
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider))
+                .Select(i => new LibraryOptionInfoDto
+                {
+                    Name = i.Name,
+                    DefaultEnabled = true
+                })
+                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(x => x.First())
+                .ToArray();
+
+            result.SubtitleFetchers = plugins
+                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher))
+                .Select(i => new LibraryOptionInfoDto
+                {
+                    Name = i.Name,
+                    DefaultEnabled = true
+                })
+                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(x => x.First())
+                .ToArray();
+
+            var typeOptions = new List<LibraryTypeOptionsDto>();
+
+            foreach (var type in types)
+            {
+                TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions);
+
+                typeOptions.Add(new LibraryTypeOptionsDto
+                {
+                    Type = type,
+
+                    MetadataFetchers = plugins
+                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                    .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher))
+                    .Select(i => new LibraryOptionInfoDto
+                    {
+                        Name = i.Name,
+                        DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary)
+                    })
+                    .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                    .Select(x => x.First())
+                    .ToArray(),
+
+                    ImageFetchers = plugins
+                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                    .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher))
+                    .Select(i => new LibraryOptionInfoDto
+                    {
+                        Name = i.Name,
+                        DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary)
+                    })
+                    .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+                    .Select(x => x.First())
+                    .ToArray(),
+
+                    SupportedImageTypes = plugins
+                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                    .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>())
+                    .Distinct()
+                    .ToArray(),
+
+                    DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>()
+                });
+            }
+
+            result.TypeOptions = typeOptions.ToArray();
+
+            return result;
+        }
+
+        private int GetCount(Type type, User? user, bool? isFavorite)
+        {
+            var query = new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = new[] { type.Name },
+                Limit = 0,
+                Recursive = true,
+                IsVirtualItem = false,
+                IsFavorite = isFavorite,
+                DtoOptions = new DtoOptions(false)
+                {
+                    EnableImages = false
+                }
+            };
+
+            return _libraryManager.GetItemsResult(query).TotalRecordCount;
+        }
+
+        private BaseItem TranslateParentItem(BaseItem item, User user)
+        {
+            return item.GetParent() is AggregateFolder
+                ? _libraryManager.GetUserRootFolder().GetChildren(user, true)
+                    .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path))
+                : item;
+        }
+
+        private void LogDownload(BaseItem item, User user, AuthorizationInfo auth)
+        {
+            try
+            {
+                _activityManager.Create(new ActivityLog(
+                    string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
+                    "UserDownloadingContent",
+                    auth.UserId)
+                {
+                    ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device),
+                });
+            }
+            catch
+            {
+                // Logged at lower levels
+            }
+        }
+
+        private QueryResult<BaseItemDto> GetSimilarItemsResult(
+            BaseItem item,
+            string? excludeArtistIds,
+            Guid userId,
+            int? limit,
+            string? fields,
+            string[] includeItemTypes,
+            bool isMovie)
+        {
+            var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request);
+
+            var query = new InternalItemsQuery(user)
+            {
+                Limit = limit,
+                IncludeItemTypes = includeItemTypes,
+                IsMovie = isMovie,
+                SimilarTo = item,
+                DtoOptions = dtoOptions,
+                EnableTotalRecordCount = !isMovie,
+                EnableGroupByMetadataKey = isMovie
+            };
+
+            // ExcludeArtistIds
+            if (!string.IsNullOrEmpty(excludeArtistIds))
+            {
+                query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+            }
+
+            List<BaseItem> itemsResult;
+
+            if (isMovie)
+            {
+                var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) };
+                if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+                {
+                    itemTypes.Add(nameof(Trailer));
+                    itemTypes.Add(nameof(LiveTvProgram));
+                }
+
+                query.IncludeItemTypes = itemTypes.ToArray();
+                itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList();
+            }
+            else if (item is MusicArtist)
+            {
+                query.IncludeItemTypes = Array.Empty<string>();
+
+                itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList();
+            }
+            else
+            {
+                itemsResult = _libraryManager.GetItemList(query);
+            }
+
+            var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
+
+            var result = new QueryResult<BaseItemDto>
+            {
+                Items = returnList,
+                TotalRecordCount = itemsResult.Count
+            };
+
+            return result;
+        }
+
+        private static string[] GetRepresentativeItemTypes(string? contentType)
+        {
+            return contentType switch
+            {
+                CollectionType.BoxSets => new[] { "BoxSet" },
+                CollectionType.Playlists => new[] { "Playlist" },
+                CollectionType.Movies => new[] { "Movie" },
+                CollectionType.TvShows => new[] { "Series", "Season", "Episode" },
+                CollectionType.Books => new[] { "Book" },
+                CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" },
+                CollectionType.HomeVideos => new[] { "Video", "Photo" },
+                CollectionType.Photos => new[] { "Video", "Photo" },
+                CollectionType.MusicVideos => new[] { "MusicVideo" },
+                _ => new[] { "Series", "Season", "Episode", "Movie" }
+            };
+        }
+
+        private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary)
+        {
+            if (isNewLibrary)
+            {
+                return false;
+            }
+
+            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+                .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
+                .ToArray();
+
+            return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase));
+        }
+
+        private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+        {
+            if (isNewLibrary)
+            {
+                if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
+                {
+                    return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
+                         || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
+                         || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase));
+                }
+
+                return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
+                   || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
+                   || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase);
+            }
+
+            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                .ToArray();
+
+            return metadataOptions.Length == 0
+               || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase));
+        }
+
+        private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
+        {
+            if (isNewLibrary)
+            {
+                if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
+                {
+                    return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase)
+                           && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
+                           && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
+                           && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase);
+                }
+
+                return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
+                       || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase)
+                       || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
+                       || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase);
+            }
+
+            var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions
+                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+                .ToArray();
+
+            if (metadataOptions.Length == 0)
+            {
+                return true;
+            }
+
+            return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase));
+        }
+    }
+}

+ 12 - 14
Jellyfin.Api/Controllers/LibraryStructureController.cs

@@ -50,13 +50,11 @@ namespace Jellyfin.Api.Controllers
         /// <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)
+        public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders()
         {
             return _libraryManager.GetVirtualFolders(true);
         }
@@ -74,8 +72,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> AddVirtualFolder(
-            [FromQuery] string name,
-            [FromQuery] string collectionType,
+            [FromQuery] string? name,
+            [FromQuery] string? collectionType,
             [FromQuery] bool refreshLibrary,
             [FromQuery] string[] paths,
             [FromQuery] LibraryOptions libraryOptions)
@@ -102,7 +100,7 @@ namespace Jellyfin.Api.Controllers
         [HttpDelete]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> RemoveVirtualFolder(
-            [FromQuery] string name,
+            [FromQuery] string? name,
             [FromQuery] bool refreshLibrary)
         {
             await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
@@ -125,8 +123,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status409Conflict)]
         public ActionResult RenameVirtualFolder(
-            [FromQuery] string name,
-            [FromQuery] string newName,
+            [FromQuery] string? name,
+            [FromQuery] string? newName,
             [FromQuery] bool refreshLibrary)
         {
             if (string.IsNullOrWhiteSpace(name))
@@ -207,8 +205,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Paths")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult AddMediaPath(
-            [FromQuery] string name,
-            [FromQuery] string path,
+            [FromQuery] string? name,
+            [FromQuery] string? path,
             [FromQuery] MediaPathInfo pathInfo,
             [FromQuery] bool refreshLibrary)
         {
@@ -258,7 +256,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Paths/Update")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult UpdateMediaPath(
-            [FromQuery] string name,
+            [FromQuery] string? name,
             [FromQuery] MediaPathInfo pathInfo)
         {
             if (string.IsNullOrWhiteSpace(name))
@@ -282,8 +280,8 @@ namespace Jellyfin.Api.Controllers
         [HttpDelete("Paths")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult RemoveMediaPath(
-            [FromQuery] string name,
-            [FromQuery] string path,
+            [FromQuery] string? name,
+            [FromQuery] string? path,
             [FromQuery] bool refreshLibrary)
         {
             if (string.IsNullOrWhiteSpace(name))
@@ -329,7 +327,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("LibraryOptions")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult UpdateLibraryOptions(
-            [FromQuery] string id,
+            [FromQuery] string? id,
             [FromQuery] LibraryOptions libraryOptions)
         {
             var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id);

+ 773 - 0
Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -0,0 +1,773 @@
+using System;
+using System.Buffers;
+using System.Globalization;
+using System.Linq;
+using System.Net.Mime;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// The media info controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class MediaInfoController : BaseJellyfinApiController
+    {
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IDeviceManager _deviceManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly INetworkManager _networkManager;
+        private readonly IMediaEncoder _mediaEncoder;
+        private readonly IUserManager _userManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly ILogger _logger;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="MediaInfoController"/> class.
+        /// </summary>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+        /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public MediaInfoController(
+            IMediaSourceManager mediaSourceManager,
+            IDeviceManager deviceManager,
+            ILibraryManager libraryManager,
+            INetworkManager networkManager,
+            IMediaEncoder mediaEncoder,
+            IUserManager userManager,
+            IAuthorizationContext authContext,
+            ILogger<MediaInfoController> logger,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _mediaSourceManager = mediaSourceManager;
+            _deviceManager = deviceManager;
+            _libraryManager = libraryManager;
+            _networkManager = networkManager;
+            _mediaEncoder = mediaEncoder;
+            _userManager = userManager;
+            _authContext = authContext;
+            _logger = logger;
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <summary>
+        /// Gets live playback media info for an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="userId">The user id.</param>
+        /// <response code="200">Playback info returned.</response>
+        /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
+        [HttpGet("/Items/{itemId}/PlaybackInfo")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute] Guid itemId, [FromQuery] Guid userId)
+        {
+            return await GetPlaybackInfoInternal(itemId, userId, null, null).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets live playback media info for an item.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="userId">The user id.</param>
+        /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
+        /// <param name="startTimeTicks">The start time in ticks.</param>
+        /// <param name="audioStreamIndex">The audio stream index.</param>
+        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+        /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
+        /// <param name="mediaSourceId">The media source id.</param>
+        /// <param name="liveStreamId">The livestream id.</param>
+        /// <param name="deviceProfile">The device profile.</param>
+        /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
+        /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
+        /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
+        /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
+        /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
+        /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
+        /// <response code="200">Playback info returned.</response>
+        /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
+        [HttpPost("/Items/{itemId}/PlaybackInfo")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
+            [FromRoute] Guid itemId,
+            [FromQuery] Guid userId,
+            [FromQuery] long? maxStreamingBitrate,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] string mediaSourceId,
+            [FromQuery] string liveStreamId,
+            [FromQuery] DeviceProfile deviceProfile,
+            [FromQuery] bool autoOpenLiveStream,
+            [FromQuery] bool enableDirectPlay = true,
+            [FromQuery] bool enableDirectStream = true,
+            [FromQuery] bool enableTranscoding = true,
+            [FromQuery] bool allowVideoStreamCopy = true,
+            [FromQuery] bool allowAudioStreamCopy = true)
+        {
+            var authInfo = _authContext.GetAuthorizationInfo(Request);
+
+            var profile = deviceProfile;
+
+            _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
+
+            if (profile == null)
+            {
+                var caps = _deviceManager.GetCapabilities(authInfo.DeviceId);
+                if (caps != null)
+                {
+                    profile = caps.DeviceProfile;
+                }
+            }
+
+            var info = await GetPlaybackInfoInternal(itemId, userId, mediaSourceId, liveStreamId).ConfigureAwait(false);
+
+            if (profile != null)
+            {
+                // set device specific data
+                var item = _libraryManager.GetItemById(itemId);
+
+                foreach (var mediaSource in info.MediaSources)
+                {
+                    SetDeviceSpecificData(
+                        item,
+                        mediaSource,
+                        profile,
+                        authInfo,
+                        maxStreamingBitrate ?? profile.MaxStreamingBitrate,
+                        startTimeTicks ?? 0,
+                        mediaSourceId,
+                        audioStreamIndex,
+                        subtitleStreamIndex,
+                        maxAudioChannels,
+                        info!.PlaySessionId!,
+                        userId,
+                        enableDirectPlay,
+                        enableDirectStream,
+                        enableTranscoding,
+                        allowVideoStreamCopy,
+                        allowAudioStreamCopy);
+                }
+
+                SortMediaSources(info, maxStreamingBitrate);
+            }
+
+            if (autoOpenLiveStream)
+            {
+                var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
+
+                if (mediaSource != null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
+                {
+                    var openStreamResult = await OpenMediaSource(new LiveStreamRequest
+                    {
+                        AudioStreamIndex = audioStreamIndex,
+                        DeviceProfile = deviceProfile,
+                        EnableDirectPlay = enableDirectPlay,
+                        EnableDirectStream = enableDirectStream,
+                        ItemId = itemId,
+                        MaxAudioChannels = maxAudioChannels,
+                        MaxStreamingBitrate = maxStreamingBitrate,
+                        PlaySessionId = info.PlaySessionId,
+                        StartTimeTicks = startTimeTicks,
+                        SubtitleStreamIndex = subtitleStreamIndex,
+                        UserId = userId,
+                        OpenToken = mediaSource.OpenToken
+                    }).ConfigureAwait(false);
+
+                    info.MediaSources = new[] { openStreamResult.MediaSource };
+                }
+            }
+
+            if (info.MediaSources != null)
+            {
+                foreach (var mediaSource in info.MediaSources)
+                {
+                    NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video);
+                }
+            }
+
+            return info;
+        }
+
+        /// <summary>
+        /// Opens a media source.
+        /// </summary>
+        /// <param name="openToken">The open token.</param>
+        /// <param name="userId">The user id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
+        /// <param name="startTimeTicks">The start time in ticks.</param>
+        /// <param name="audioStreamIndex">The audio stream index.</param>
+        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+        /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="deviceProfile">The device profile.</param>
+        /// <param name="directPlayProtocols">The direct play protocols. Default: <see cref="MediaProtocol.Http"/>.</param>
+        /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
+        /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
+        /// <response code="200">Media source opened.</response>
+        /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns>
+        [HttpPost("/LiveStreams/Open")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream(
+            [FromQuery] string openToken,
+            [FromQuery] Guid userId,
+            [FromQuery] string playSessionId,
+            [FromQuery] long? maxStreamingBitrate,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] Guid itemId,
+            [FromQuery] DeviceProfile deviceProfile,
+            [FromQuery] MediaProtocol[] directPlayProtocols,
+            [FromQuery] bool enableDirectPlay = true,
+            [FromQuery] bool enableDirectStream = true)
+        {
+            var request = new LiveStreamRequest
+            {
+                OpenToken = openToken,
+                UserId = userId,
+                PlaySessionId = playSessionId,
+                MaxStreamingBitrate = maxStreamingBitrate,
+                StartTimeTicks = startTimeTicks,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                MaxAudioChannels = maxAudioChannels,
+                ItemId = itemId,
+                DeviceProfile = deviceProfile,
+                EnableDirectPlay = enableDirectPlay,
+                EnableDirectStream = enableDirectStream,
+                DirectPlayProtocols = directPlayProtocols ?? new[] { MediaProtocol.Http }
+            };
+            return await OpenMediaSource(request).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Closes a media source.
+        /// </summary>
+        /// <param name="liveStreamId">The livestream id.</param>
+        /// <response code="204">Livestream closed.</response>
+        /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+        [HttpPost("/LiveStreams/Close")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult CloseLiveStream([FromQuery] string liveStreamId)
+        {
+            _mediaSourceManager.CloseLiveStream(liveStreamId).GetAwaiter().GetResult();
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Tests the network with a request with the size of the bitrate.
+        /// </summary>
+        /// <param name="size">The bitrate. Defaults to 102400.</param>
+        /// <response code="200">Test buffer returned.</response>
+        /// <response code="400">Size has to be a numer between 0 and 10,000,000.</response>
+        /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns>
+        [HttpGet("/Playback/BitrateTest")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status400BadRequest)]
+        [Produces(MediaTypeNames.Application.Octet)]
+        public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400)
+        {
+            const int MaxSize = 10_000_000;
+
+            if (size <= 0)
+            {
+                return BadRequest($"The requested size ({size}) is equal to or smaller than 0.");
+            }
+
+            if (size > MaxSize)
+            {
+                return BadRequest($"The requested size ({size}) is larger than the max allowed value ({MaxSize}).");
+            }
+
+            byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
+            try
+            {
+                new Random().NextBytes(buffer);
+                return File(buffer, MediaTypeNames.Application.Octet);
+            }
+            finally
+            {
+                ArrayPool<byte>.Shared.Return(buffer);
+            }
+        }
+
+        private async Task<PlaybackInfoResponse> GetPlaybackInfoInternal(
+            Guid id,
+            Guid userId,
+            string? mediaSourceId = null,
+            string? liveStreamId = null)
+        {
+            var user = _userManager.GetUserById(userId);
+            var item = _libraryManager.GetItemById(id);
+            var result = new PlaybackInfoResponse();
+
+            MediaSourceInfo[] mediaSources;
+            if (string.IsNullOrWhiteSpace(liveStreamId))
+            {
+                // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
+                var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
+
+                if (string.IsNullOrWhiteSpace(mediaSourceId))
+                {
+                    mediaSources = mediaSourcesList.ToArray();
+                }
+                else
+                {
+                    mediaSources = mediaSourcesList
+                        .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
+                        .ToArray();
+                }
+            }
+            else
+            {
+                var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
+
+                mediaSources = new[] { mediaSource };
+            }
+
+            if (mediaSources.Length == 0)
+            {
+                result.MediaSources = Array.Empty<MediaSourceInfo>();
+
+                result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
+            }
+            else
+            {
+                // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it
+                // Should we move this directly into MediaSourceManager?
+                result.MediaSources = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources));
+
+                result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+            }
+
+            return result;
+        }
+
+        private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
+        {
+            mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type);
+        }
+
+        private void SetDeviceSpecificData(
+            BaseItem item,
+            MediaSourceInfo mediaSource,
+            DeviceProfile profile,
+            AuthorizationInfo auth,
+            long? maxBitrate,
+            long startTimeTicks,
+            string mediaSourceId,
+            int? audioStreamIndex,
+            int? subtitleStreamIndex,
+            int? maxAudioChannels,
+            string playSessionId,
+            Guid userId,
+            bool enableDirectPlay,
+            bool enableDirectStream,
+            bool enableTranscoding,
+            bool allowVideoStreamCopy,
+            bool allowAudioStreamCopy)
+        {
+            var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
+
+            var options = new VideoOptions
+            {
+                MediaSources = new[] { mediaSource },
+                Context = EncodingContext.Streaming,
+                DeviceId = auth.DeviceId,
+                ItemId = item.Id,
+                Profile = profile,
+                MaxAudioChannels = maxAudioChannels
+            };
+
+            if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
+            {
+                options.MediaSourceId = mediaSourceId;
+                options.AudioStreamIndex = audioStreamIndex;
+                options.SubtitleStreamIndex = subtitleStreamIndex;
+            }
+
+            var user = _userManager.GetUserById(userId);
+
+            if (!enableDirectPlay)
+            {
+                mediaSource.SupportsDirectPlay = false;
+            }
+
+            if (!enableDirectStream)
+            {
+                mediaSource.SupportsDirectStream = false;
+            }
+
+            if (!enableTranscoding)
+            {
+                mediaSource.SupportsTranscoding = false;
+            }
+
+            if (item is Audio)
+            {
+                _logger.LogInformation(
+                    "User policy for {0}. EnableAudioPlaybackTranscoding: {1}",
+                    user.Username,
+                    user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
+            }
+            else
+            {
+                _logger.LogInformation(
+                    "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
+                    user.Username,
+                    user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
+                    user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
+                    user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
+            }
+
+            // Beginning of Playback Determination: Attempt DirectPlay first
+            if (mediaSource.SupportsDirectPlay)
+            {
+                if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
+                {
+                    mediaSource.SupportsDirectPlay = false;
+                }
+                else
+                {
+                    var supportsDirectStream = mediaSource.SupportsDirectStream;
+
+                    // Dummy this up to fool StreamBuilder
+                    mediaSource.SupportsDirectStream = true;
+                    options.MaxBitrate = maxBitrate;
+
+                    if (item is Audio)
+                    {
+                        if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
+                        {
+                            options.ForceDirectPlay = true;
+                        }
+                    }
+                    else if (item is Video)
+                    {
+                        if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
+                            && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
+                            && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
+                        {
+                            options.ForceDirectPlay = true;
+                        }
+                    }
+
+                    // The MediaSource supports direct stream, now test to see if the client supports it
+                    var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
+                        ? streamBuilder.BuildAudioItem(options)
+                        : streamBuilder.BuildVideoItem(options);
+
+                    if (streamInfo == null || !streamInfo.IsDirectStream)
+                    {
+                        mediaSource.SupportsDirectPlay = false;
+                    }
+
+                    // Set this back to what it was
+                    mediaSource.SupportsDirectStream = supportsDirectStream;
+
+                    if (streamInfo != null)
+                    {
+                        SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+                    }
+                }
+            }
+
+            if (mediaSource.SupportsDirectStream)
+            {
+                if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
+                {
+                    mediaSource.SupportsDirectStream = false;
+                }
+                else
+                {
+                    options.MaxBitrate = GetMaxBitrate(maxBitrate, user);
+
+                    if (item is Audio)
+                    {
+                        if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
+                        {
+                            options.ForceDirectStream = true;
+                        }
+                    }
+                    else if (item is Video)
+                    {
+                        if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
+                            && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
+                            && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
+                        {
+                            options.ForceDirectStream = true;
+                        }
+                    }
+
+                    // The MediaSource supports direct stream, now test to see if the client supports it
+                    var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
+                        ? streamBuilder.BuildAudioItem(options)
+                        : streamBuilder.BuildVideoItem(options);
+
+                    if (streamInfo == null || !streamInfo.IsDirectStream)
+                    {
+                        mediaSource.SupportsDirectStream = false;
+                    }
+
+                    if (streamInfo != null)
+                    {
+                        SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+                    }
+                }
+            }
+
+            if (mediaSource.SupportsTranscoding)
+            {
+                options.MaxBitrate = GetMaxBitrate(maxBitrate, user);
+
+                // The MediaSource supports direct stream, now test to see if the client supports it
+                var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
+                    ? streamBuilder.BuildAudioItem(options)
+                    : streamBuilder.BuildVideoItem(options);
+
+                if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
+                {
+                    if (streamInfo != null)
+                    {
+                        streamInfo.PlaySessionId = playSessionId;
+                        streamInfo.StartPositionTicks = startTimeTicks;
+                        mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
+                        mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
+                        mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
+                        mediaSource.TranscodingContainer = streamInfo.Container;
+                        mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+
+                        // Do this after the above so that StartPositionTicks is set
+                        SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+                    }
+                }
+                else
+                {
+                    if (streamInfo != null)
+                    {
+                        streamInfo.PlaySessionId = playSessionId;
+
+                        if (streamInfo.PlayMethod == PlayMethod.Transcode)
+                        {
+                            streamInfo.StartPositionTicks = startTimeTicks;
+                            mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
+
+                            if (!allowVideoStreamCopy)
+                            {
+                                mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
+                            }
+
+                            if (!allowAudioStreamCopy)
+                            {
+                                mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
+                            }
+
+                            mediaSource.TranscodingContainer = streamInfo.Container;
+                            mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+                        }
+
+                        if (!allowAudioStreamCopy)
+                        {
+                            mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
+                        }
+
+                        mediaSource.TranscodingContainer = streamInfo.Container;
+                        mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+
+                        // Do this after the above so that StartPositionTicks is set
+                        SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
+                    }
+                }
+            }
+
+            foreach (var attachment in mediaSource.MediaAttachments)
+            {
+                attachment.DeliveryUrl = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "/Videos/{0}/{1}/Attachments/{2}",
+                    item.Id,
+                    mediaSource.Id,
+                    attachment.Index);
+            }
+        }
+
+        private async Task<LiveStreamResponse> OpenMediaSource(LiveStreamRequest request)
+        {
+            var authInfo = _authContext.GetAuthorizationInfo(Request);
+
+            var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
+
+            var profile = request.DeviceProfile;
+            if (profile == null)
+            {
+                var caps = _deviceManager.GetCapabilities(authInfo.DeviceId);
+                if (caps != null)
+                {
+                    profile = caps.DeviceProfile;
+                }
+            }
+
+            if (profile != null)
+            {
+                var item = _libraryManager.GetItemById(request.ItemId);
+
+                SetDeviceSpecificData(
+                    item,
+                    result.MediaSource,
+                    profile,
+                    authInfo,
+                    request.MaxStreamingBitrate,
+                    request.StartTimeTicks ?? 0,
+                    result.MediaSource.Id,
+                    request.AudioStreamIndex,
+                    request.SubtitleStreamIndex,
+                    request.MaxAudioChannels,
+                    request.PlaySessionId,
+                    request.UserId,
+                    request.EnableDirectPlay,
+                    request.EnableDirectStream,
+                    true,
+                    true,
+                    true);
+            }
+            else
+            {
+                if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
+                {
+                    result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
+                }
+            }
+
+            // here was a check if (result.MediaSource != null) but Rider said it will never be null
+            NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
+
+            return result;
+        }
+
+        private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
+        {
+            var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken);
+            mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex;
+
+            mediaSource.TranscodeReasons = info.TranscodeReasons;
+
+            foreach (var profile in profiles)
+            {
+                foreach (var stream in mediaSource.MediaStreams)
+                {
+                    if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index)
+                    {
+                        stream.DeliveryMethod = profile.DeliveryMethod;
+
+                        if (profile.DeliveryMethod == SubtitleDeliveryMethod.External)
+                        {
+                            stream.DeliveryUrl = profile.Url.TrimStart('-');
+                            stream.IsExternalUrl = profile.IsExternalUrl;
+                        }
+                    }
+                }
+            }
+        }
+
+        private long? GetMaxBitrate(long? clientMaxBitrate, User user)
+        {
+            var maxBitrate = clientMaxBitrate;
+            var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0;
+
+            if (remoteClientMaxBitrate <= 0)
+            {
+                remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
+            }
+
+            if (remoteClientMaxBitrate > 0)
+            {
+                var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString());
+
+                _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.HttpContext.Connection.RemoteIpAddress.ToString(), isInLocalNetwork);
+                if (!isInLocalNetwork)
+                {
+                    maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
+                }
+            }
+
+            return maxBitrate;
+        }
+
+        private void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
+        {
+            var originalList = result.MediaSources.ToList();
+
+            result.MediaSources = result.MediaSources.OrderBy(i =>
+                {
+                    // Nothing beats direct playing a file
+                    if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
+                    {
+                        return 0;
+                    }
+
+                    return 1;
+                })
+                .ThenBy(i =>
+                {
+                    // Let's assume direct streaming a file is just as desirable as direct playing a remote url
+                    if (i.SupportsDirectPlay || i.SupportsDirectStream)
+                    {
+                        return 0;
+                    }
+
+                    return 1;
+                })
+                .ThenBy(i =>
+                {
+                    return i.Protocol switch
+                    {
+                        MediaProtocol.File => 0,
+                        _ => 1,
+                    };
+                })
+                .ThenBy(i =>
+                {
+                    if (maxBitrate.HasValue && i.Bitrate.HasValue)
+                    {
+                        return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
+                    }
+
+                    return 1;
+                })
+                .ThenBy(originalList.IndexOf)
+                .ToArray();
+        }
+    }
+}

+ 340 - 0
Jellyfin.Api/Controllers/MoviesController.cs

@@ -0,0 +1,340 @@
+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 Jellyfin.Data.Entities;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Movies controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class MoviesController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IDtoService _dtoService;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="MoviesController"/> 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="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        public MoviesController(
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            IServerConfigurationManager serverConfigurationManager)
+        {
+            _userManager = userManager;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+            _serverConfigurationManager = serverConfigurationManager;
+        }
+
+        /// <summary>
+        /// Gets movie recommendations.
+        /// </summary>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+        /// <param name="enableImages">(Unused) Optional. include image information in output.</param>
+        /// <param name="enableUserData">(Unused) Optional. include user data.</param>
+        /// <param name="imageTypeLimit">(Unused) Optional. the max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">(Unused) Optional. The image types to include in the output.</param>
+        /// <param name="fields">Optional. The fields to return.</param>
+        /// <param name="categoryLimit">The max number of categories to return.</param>
+        /// <param name="itemLimit">The max number of items to return per category.</param>
+        /// <response code="200">Movie recommendations returned.</response>
+        /// <returns>The list of movie recommendations.</returns>
+        [HttpGet("Recommendations")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
+        public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
+            [FromQuery] Guid userId,
+            [FromQuery] string parentId,
+            [FromQuery] bool? enableImages,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] string? fields,
+            [FromQuery] int categoryLimit = 5,
+            [FromQuery] int itemLimit = 8)
+        {
+            var user = _userManager.GetUserById(userId);
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request);
+
+            var categories = new List<RecommendationDto>();
+
+            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+
+            var query = new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = new[]
+                {
+                    nameof(Movie),
+                    // typeof(Trailer).Name,
+                    // typeof(LiveTvProgram).Name
+                },
+                // IsMovie = true
+                OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+                Limit = 7,
+                ParentId = parentIdGuid,
+                Recursive = true,
+                IsPlayed = true,
+                DtoOptions = dtoOptions
+            };
+
+            var recentlyPlayedMovies = _libraryManager.GetItemList(query);
+
+            var itemTypes = new List<string> { nameof(Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = itemTypes.ToArray(),
+                IsMovie = true,
+                OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+                Limit = 10,
+                IsFavoriteOrLiked = true,
+                ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
+                EnableGroupByMetadataKey = true,
+                ParentId = parentIdGuid,
+                Recursive = true,
+                DtoOptions = dtoOptions
+            });
+
+            var mostRecentMovies = recentlyPlayedMovies.Take(6).ToList();
+            // Get recently played directors
+            var recentDirectors = GetDirectors(mostRecentMovies)
+                .ToList();
+
+            // Get recently played actors
+            var recentActors = GetActors(mostRecentMovies)
+                .ToList();
+
+            var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
+            var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
+
+            var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
+            var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
+
+            var categoryTypes = new List<IEnumerator<RecommendationDto>>
+            {
+                // Give this extra weight
+                similarToRecentlyPlayed,
+                similarToRecentlyPlayed,
+
+                // Give this extra weight
+                similarToLiked,
+                similarToLiked,
+                hasDirectorFromRecentlyPlayed,
+                hasActorFromRecentlyPlayed
+            };
+
+            while (categories.Count < categoryLimit)
+            {
+                var allEmpty = true;
+
+                foreach (var category in categoryTypes)
+                {
+                    if (category.MoveNext())
+                    {
+                        categories.Add(category.Current);
+                        allEmpty = false;
+
+                        if (categories.Count >= categoryLimit)
+                        {
+                            break;
+                        }
+                    }
+                }
+
+                if (allEmpty)
+                {
+                    break;
+                }
+            }
+
+            return Ok(categories.OrderBy(i => i.RecommendationType));
+        }
+
+        private IEnumerable<RecommendationDto> GetWithDirector(
+            User user,
+            IEnumerable<string> names,
+            int itemLimit,
+            DtoOptions dtoOptions,
+            RecommendationType type)
+        {
+            var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            foreach (var name in names)
+            {
+                var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+                    {
+                        Person = name,
+                        // Account for duplicates by imdb id, since the database doesn't support this yet
+                        Limit = itemLimit + 2,
+                        PersonTypes = new[] { PersonType.Director },
+                        IncludeItemTypes = itemTypes.ToArray(),
+                        IsMovie = true,
+                        EnableGroupByMetadataKey = true,
+                        DtoOptions = dtoOptions
+                    }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+                    .Select(x => x.First())
+                    .Take(itemLimit)
+                    .ToList();
+
+                if (items.Count > 0)
+                {
+                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+
+                    yield return new RecommendationDto
+                    {
+                        BaselineItemName = name,
+                        CategoryId = name.GetMD5(),
+                        RecommendationType = type,
+                        Items = returnItems
+                    };
+                }
+            }
+        }
+
+        private IEnumerable<RecommendationDto> GetWithActor(User user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+        {
+            var itemTypes = new List<string> { nameof(Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            foreach (var name in names)
+            {
+                var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+                    {
+                        Person = name,
+                        // Account for duplicates by imdb id, since the database doesn't support this yet
+                        Limit = itemLimit + 2,
+                        IncludeItemTypes = itemTypes.ToArray(),
+                        IsMovie = true,
+                        EnableGroupByMetadataKey = true,
+                        DtoOptions = dtoOptions
+                    }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+                    .Select(x => x.First())
+                    .Take(itemLimit)
+                    .ToList();
+
+                if (items.Count > 0)
+                {
+                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+
+                    yield return new RecommendationDto
+                    {
+                        BaselineItemName = name,
+                        CategoryId = name.GetMD5(),
+                        RecommendationType = type,
+                        Items = returnItems
+                    };
+                }
+            }
+        }
+
+        private IEnumerable<RecommendationDto> GetSimilarTo(User user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+        {
+            var itemTypes = new List<string> { nameof(Movie) };
+            if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+            {
+                itemTypes.Add(nameof(Trailer));
+                itemTypes.Add(nameof(LiveTvProgram));
+            }
+
+            foreach (var item in baselineItems)
+            {
+                var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
+                {
+                    Limit = itemLimit,
+                    IncludeItemTypes = itemTypes.ToArray(),
+                    IsMovie = true,
+                    SimilarTo = item,
+                    EnableGroupByMetadataKey = true,
+                    DtoOptions = dtoOptions
+                });
+
+                if (similar.Count > 0)
+                {
+                    var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
+
+                    yield return new RecommendationDto
+                    {
+                        BaselineItemName = item.Name,
+                        CategoryId = item.Id,
+                        RecommendationType = type,
+                        Items = returnItems
+                    };
+                }
+            }
+        }
+
+        private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
+        {
+            var people = _libraryManager.GetPeople(new InternalPeopleQuery
+            {
+                ExcludePersonTypes = new[] { PersonType.Director },
+                MaxListOrder = 3
+            });
+
+            var itemIds = items.Select(i => i.Id).ToList();
+
+            return people
+                .Where(i => itemIds.Contains(i.ItemId))
+                .Select(i => i.Name)
+                .DistinctNames();
+        }
+
+        private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
+        {
+            var people = _libraryManager.GetPeople(new InternalPeopleQuery
+            {
+                PersonTypes = new[] { PersonType.Director }
+            });
+
+            var itemIds = items.Select(i => i.Id).ToList();
+
+            return people
+                .Where(i => itemIds.Contains(i.ItemId))
+                .Select(i => i.Name)
+                .DistinctNames();
+        }
+    }
+}

+ 6 - 33
Jellyfin.Api/Controllers/NotificationsController.cs

@@ -36,23 +36,11 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets a user's notifications.
         /// </summary>
-        /// <param name="userId">The user's ID.</param>
-        /// <param name="isRead">An optional filter by notification read state.</param>
-        /// <param name="startIndex">The optional index to start at. All notifications with a lower index will be omitted from the results.</param>
-        /// <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}")]
         [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,
-            [FromQuery] int? startIndex,
-            [FromQuery] int? limit)
+        public ActionResult<NotificationResultDto> GetNotifications()
         {
             return new NotificationResultDto();
         }
@@ -60,14 +48,11 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets a user's notification summary.
         /// </summary>
-        /// <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")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
-        public ActionResult<NotificationsSummaryDto> GetNotificationsSummary(
-            [FromRoute] string userId)
+        public ActionResult<NotificationsSummaryDto> GetNotificationsSummary()
         {
             return new NotificationsSummaryDto();
         }
@@ -108,8 +93,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Admin")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult CreateAdminNotification(
-            [FromQuery] string name,
-            [FromQuery] string description,
+            [FromQuery] string? name,
+            [FromQuery] string? description,
             [FromQuery] string? url,
             [FromQuery] NotificationLevel? level)
         {
@@ -134,17 +119,11 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Sets notifications as read.
         /// </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="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)
+        public ActionResult SetRead()
         {
             return NoContent();
         }
@@ -152,17 +131,11 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Sets notifications as unread.
         /// </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="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)
+        public ActionResult SetUnread()
         {
             return NoContent();
         }

+ 4 - 4
Jellyfin.Api/Controllers/PackageController.cs

@@ -40,7 +40,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("/{name}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult<PackageInfo>> GetPackageInfo(
-            [FromRoute] [Required] string name,
+            [FromRoute] [Required] string? name,
             [FromQuery] string? assemblyGuid)
         {
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
@@ -80,9 +80,9 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [Authorize(Policy = Policies.RequiresElevation)]
         public async Task<ActionResult> InstallPackage(
-            [FromRoute] [Required] string name,
-            [FromQuery] string assemblyGuid,
-            [FromQuery] string version)
+            [FromRoute] [Required] string? name,
+            [FromQuery] string? assemblyGuid,
+            [FromQuery] string? version)
         {
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             var package = _installationManager.GetCompatibleVersions(

+ 7 - 7
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -84,8 +84,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("{playlistId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult AddToPlaylist(
-            [FromRoute] string playlistId,
-            [FromQuery] string ids,
+            [FromRoute] string? playlistId,
+            [FromQuery] string? ids,
             [FromQuery] Guid userId)
         {
             _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId);
@@ -103,8 +103,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult MoveItem(
-            [FromRoute] string playlistId,
-            [FromRoute] string itemId,
+            [FromRoute] string? playlistId,
+            [FromRoute] string? itemId,
             [FromRoute] int newIndex)
         {
             _playlistManager.MoveItem(playlistId, itemId, newIndex);
@@ -120,7 +120,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>An <see cref="NoContentResult"/> on success.</returns>
         [HttpDelete("{playlistId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult RemoveFromPlaylist([FromRoute] string playlistId, [FromQuery] string entryIds)
+        public ActionResult RemoveFromPlaylist([FromRoute] string? playlistId, [FromQuery] string? entryIds)
         {
             _playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true));
             return NoContent();
@@ -147,11 +147,11 @@ namespace Jellyfin.Api.Controllers
             [FromRoute] Guid userId,
             [FromRoute] int? startIndex,
             [FromRoute] int? limit,
-            [FromRoute] string fields,
+            [FromRoute] string? fields,
             [FromRoute] bool? enableImages,
             [FromRoute] bool? enableUserData,
             [FromRoute] int? imageTypeLimit,
-            [FromRoute] string enableImageTypes)
+            [FromRoute] string? enableImageTypes)
         {
             var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
             if (playlist == null)

+ 372 - 0
Jellyfin.Api/Controllers/PlaystateController.cs

@@ -0,0 +1,372 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Playstate controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class PlaystateController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly IUserDataManager _userDataRepository;
+        private readonly ILibraryManager _libraryManager;
+        private readonly ISessionManager _sessionManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly ILogger<PlaystateController> _logger;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlaystateController"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        public PlaystateController(
+            IUserManager userManager,
+            IUserDataManager userDataRepository,
+            ILibraryManager libraryManager,
+            ISessionManager sessionManager,
+            IAuthorizationContext authContext,
+            ILoggerFactory loggerFactory,
+            IMediaSourceManager mediaSourceManager,
+            IFileSystem fileSystem)
+        {
+            _userManager = userManager;
+            _userDataRepository = userDataRepository;
+            _libraryManager = libraryManager;
+            _sessionManager = sessionManager;
+            _authContext = authContext;
+            _logger = loggerFactory.CreateLogger<PlaystateController>();
+
+            _transcodingJobHelper = new TranscodingJobHelper(
+                loggerFactory.CreateLogger<TranscodingJobHelper>(),
+                mediaSourceManager,
+                fileSystem);
+        }
+
+        /// <summary>
+        /// Marks an item as played for user.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="datePlayed">Optional. The date the item was played.</param>
+        /// <response code="200">Item marked as played.</response>
+        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpPost("/Users/{userId}/PlayedItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> MarkPlayedItem(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] DateTime? datePlayed)
+        {
+            var user = _userManager.GetUserById(userId);
+            var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var dto = UpdatePlayedStatus(user, itemId, true, datePlayed);
+            foreach (var additionalUserInfo in session.AdditionalUsers)
+            {
+                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+                UpdatePlayedStatus(additionalUser, itemId, true, datePlayed);
+            }
+
+            return dto;
+        }
+
+        /// <summary>
+        /// Marks an item as unplayed for user.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Item marked as unplayed.</response>
+        /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpDelete("/Users/{userId}/PlayedItem/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+            var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var dto = UpdatePlayedStatus(user, itemId, false, null);
+            foreach (var additionalUserInfo in session.AdditionalUsers)
+            {
+                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+                UpdatePlayedStatus(additionalUser, itemId, false, null);
+            }
+
+            return dto;
+        }
+
+        /// <summary>
+        /// Reports playback has started within a session.
+        /// </summary>
+        /// <param name="playbackStartInfo">The playback start info.</param>
+        /// <response code="204">Playback start recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo)
+        {
+            playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+            playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports playback progress within a session.
+        /// </summary>
+        /// <param name="playbackProgressInfo">The playback progress info.</param>
+        /// <response code="204">Playback progress recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing/Progress")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo)
+        {
+            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+            playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Pings a playback session.
+        /// </summary>
+        /// <param name="playSessionId">Playback session id.</param>
+        /// <response code="204">Playback session pinged.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing/Ping")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult PingPlaybackSession([FromQuery] string playSessionId)
+        {
+            _transcodingJobHelper.PingTranscodingJob(playSessionId, null);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports playback has stopped within a session.
+        /// </summary>
+        /// <param name="playbackStopInfo">The playback stop info.</param>
+        /// <response code="204">Playback stop recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing/Stopped")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo)
+        {
+            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+            {
+                await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+            }
+
+            playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that a user has begun playing an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="mediaSourceId">The id of the MediaSource.</param>
+        /// <param name="canSeek">Indicates if the client can seek.</param>
+        /// <param name="audioStreamIndex">The audio stream index.</param>
+        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+        /// <param name="playMethod">The play method.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <response code="204">Play start recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Users/{userId}/PlayingItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+        public async Task<ActionResult> OnPlaybackStart(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] string mediaSourceId,
+            [FromQuery] bool canSeek,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] PlayMethod playMethod,
+            [FromQuery] string liveStreamId,
+            [FromQuery] string playSessionId)
+        {
+            var playbackStartInfo = new PlaybackStartInfo
+            {
+                CanSeek = canSeek,
+                ItemId = itemId,
+                MediaSourceId = mediaSourceId,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                PlayMethod = playMethod,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId
+            };
+
+            playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+            playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports a user's playback progress.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="mediaSourceId">The id of the MediaSource.</param>
+        /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
+        /// <param name="isPaused">Indicates if the player is paused.</param>
+        /// <param name="isMuted">Indicates if the player is muted.</param>
+        /// <param name="audioStreamIndex">The audio stream index.</param>
+        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+        /// <param name="volumeLevel">Scale of 0-100.</param>
+        /// <param name="playMethod">The play method.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <param name="repeatMode">The repeat mode.</param>
+        /// <response code="204">Play progress recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Users/{userId}/PlayingItems/{itemId}/Progress")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+        public async Task<ActionResult> OnPlaybackProgress(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] string mediaSourceId,
+            [FromQuery] long? positionTicks,
+            [FromQuery] bool isPaused,
+            [FromQuery] bool isMuted,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] int? volumeLevel,
+            [FromQuery] PlayMethod playMethod,
+            [FromQuery] string liveStreamId,
+            [FromQuery] string playSessionId,
+            [FromQuery] RepeatMode repeatMode)
+        {
+            var playbackProgressInfo = new PlaybackProgressInfo
+            {
+                ItemId = itemId,
+                PositionTicks = positionTicks,
+                IsMuted = isMuted,
+                IsPaused = isPaused,
+                MediaSourceId = mediaSourceId,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                VolumeLevel = volumeLevel,
+                PlayMethod = playMethod,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId,
+                RepeatMode = repeatMode
+            };
+
+            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+            playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that a user has stopped playing an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="mediaSourceId">The id of the MediaSource.</param>
+        /// <param name="nextMediaType">The next media type that will play.</param>
+        /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <response code="204">Playback stop recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("/Users/{userId}/PlayingItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+        public async Task<ActionResult> OnPlaybackStopped(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] string mediaSourceId,
+            [FromQuery] string nextMediaType,
+            [FromQuery] long? positionTicks,
+            [FromQuery] string liveStreamId,
+            [FromQuery] string playSessionId)
+        {
+            var playbackStopInfo = new PlaybackStopInfo
+            {
+                ItemId = itemId,
+                PositionTicks = positionTicks,
+                MediaSourceId = mediaSourceId,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId,
+                NextMediaType = nextMediaType
+            };
+
+            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+            {
+                await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+            }
+
+            playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates the played status.
+        /// </summary>
+        /// <param name="user">The user.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
+        /// <param name="datePlayed">The date played.</param>
+        /// <returns>Task.</returns>
+        private UserItemDataDto UpdatePlayedStatus(User user, Guid itemId, bool wasPlayed, DateTime? datePlayed)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            if (wasPlayed)
+            {
+                item.MarkPlayed(user, datePlayed, true);
+            }
+            else
+            {
+                item.MarkUnplayed(user);
+            }
+
+            return _userDataRepository.GetUserDataDto(item, user);
+        }
+
+        private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId)
+        {
+            if (method == PlayMethod.Transcode)
+            {
+                var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId);
+                if (job == null)
+                {
+                    return PlayMethod.DirectPlay;
+                }
+            }
+
+            return method;
+        }
+    }
+}

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

@@ -42,13 +42,11 @@ namespace Jellyfin.Api.Controllers
         /// <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)
+        public ActionResult<IEnumerable<PluginInfo>> GetPlugins()
         {
             return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo()));
         }
@@ -168,7 +166,7 @@ namespace Jellyfin.Api.Controllers
         [Obsolete("This endpoint should not be used.")]
         [HttpPost("RegistrationRecords/{name}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string name)
+        public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string? name)
         {
             return new MBRegistrationRecord
             {
@@ -190,7 +188,7 @@ namespace Jellyfin.Api.Controllers
         [Obsolete("Paid plugins are not supported")]
         [HttpGet("/Registrations/{name}")]
         [ProducesResponseType(StatusCodes.Status501NotImplemented)]
-        public ActionResult GetRegistration([FromRoute] string name)
+        public ActionResult GetRegistration([FromRoute] string? name)
         {
             // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins,
             // delete all these registration endpoints. They are only kept for compatibility.

+ 1 - 1
Jellyfin.Api/Controllers/RemoteImageController.cs

@@ -208,7 +208,7 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> DownloadRemoteImage(
             [FromRoute] Guid itemId,
             [FromQuery, BindRequired] ImageType type,
-            [FromQuery] string imageUrl)
+            [FromQuery] string? imageUrl)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)

+ 161 - 0
Jellyfin.Api/Controllers/ScheduledTasksController.cs

@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Model.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Scheduled Tasks Controller.
+    /// </summary>
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public class ScheduledTasksController : BaseJellyfinApiController
+    {
+        private readonly ITaskManager _taskManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class.
+        /// </summary>
+        /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param>
+        public ScheduledTasksController(ITaskManager taskManager)
+        {
+            _taskManager = taskManager;
+        }
+
+        /// <summary>
+        /// Get tasks.
+        /// </summary>
+        /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param>
+        /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param>
+        /// <response code="200">Scheduled tasks retrieved.</response>
+        /// <returns>The list of scheduled tasks.</returns>
+        [HttpGet]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public IEnumerable<IScheduledTaskWorker> GetTasks(
+            [FromQuery] bool? isHidden,
+            [FromQuery] bool? isEnabled)
+        {
+            IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name);
+
+            foreach (var task in tasks)
+            {
+                if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask)
+                {
+                    if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden)
+                    {
+                        continue;
+                    }
+
+                    if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled)
+                    {
+                        continue;
+                    }
+                }
+
+                yield return task;
+            }
+        }
+
+        /// <summary>
+        /// Get task by id.
+        /// </summary>
+        /// <param name="taskId">Task Id.</param>
+        /// <response code="200">Task retrieved.</response>
+        /// <response code="404">Task not found.</response>
+        /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns>
+        [HttpGet("{taskId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<TaskInfo> GetTask([FromRoute] string? taskId)
+        {
+            var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
+                string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
+
+            if (task == null)
+            {
+                return NotFound();
+            }
+
+            return ScheduledTaskHelpers.GetTaskInfo(task);
+        }
+
+        /// <summary>
+        /// Start specified task.
+        /// </summary>
+        /// <param name="taskId">Task Id.</param>
+        /// <response code="204">Task started.</response>
+        /// <response code="404">Task not found.</response>
+        /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+        [HttpPost("Running/{taskId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult StartTask([FromRoute] string? taskId)
+        {
+            var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+                o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+
+            if (task == null)
+            {
+                return NotFound();
+            }
+
+            _taskManager.Execute(task, new TaskOptions());
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Stop specified task.
+        /// </summary>
+        /// <param name="taskId">Task Id.</param>
+        /// <response code="204">Task stopped.</response>
+        /// <response code="404">Task not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+        [HttpDelete("Running/{taskId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult StopTask([FromRoute] string? taskId)
+        {
+            var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+                o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+
+            if (task == null)
+            {
+                return NotFound();
+            }
+
+            _taskManager.Cancel(task);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Update specified task triggers.
+        /// </summary>
+        /// <param name="taskId">Task Id.</param>
+        /// <param name="triggerInfos">Triggers.</param>
+        /// <response code="204">Task triggers updated.</response>
+        /// <response code="404">Task not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+        [HttpPost("{taskId}/Triggers")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult UpdateTask(
+            [FromRoute] string? taskId,
+            [FromBody, BindRequired] TaskTriggerInfo[] triggerInfos)
+        {
+            var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+                o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+            if (task == null)
+            {
+                return NotFound();
+            }
+
+            task.Triggers = triggerInfos;
+            return NoContent();
+        }
+    }
+}

+ 5 - 5
Jellyfin.Api/Controllers/SearchController.cs

@@ -81,11 +81,11 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] Guid userId,
-            [FromQuery, Required] string searchTerm,
-            [FromQuery] string includeItemTypes,
-            [FromQuery] string excludeItemTypes,
-            [FromQuery] string mediaTypes,
-            [FromQuery] string parentId,
+            [FromQuery, Required] string? searchTerm,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? parentId,
             [FromQuery] bool? isMovie,
             [FromQuery] bool? isSeries,
             [FromQuery] bool? isNews,

+ 23 - 23
Jellyfin.Api/Controllers/SessionController.cs

@@ -62,7 +62,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<IEnumerable<SessionInfo>> GetSessions(
             [FromQuery] Guid controllableByUserId,
-            [FromQuery] string deviceId,
+            [FromQuery] string? deviceId,
             [FromQuery] int? activeWithinSeconds)
         {
             var result = _sessionManager.Sessions;
@@ -123,10 +123,10 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/Viewing")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult DisplayContent(
-            [FromRoute] string sessionId,
-            [FromQuery] string itemType,
-            [FromQuery] string itemId,
-            [FromQuery] string itemName)
+            [FromRoute] string? sessionId,
+            [FromQuery] string? itemType,
+            [FromQuery] string? itemId,
+            [FromQuery] string? itemName)
         {
             var command = new BrowseRequest
             {
@@ -157,7 +157,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/Playing")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult Play(
-            [FromRoute] string sessionId,
+            [FromRoute] string? sessionId,
             [FromQuery] Guid[] itemIds,
             [FromQuery] long? startPositionTicks,
             [FromQuery] PlayCommand playCommand,
@@ -191,7 +191,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/Playing/{command}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SendPlaystateCommand(
-            [FromRoute] string sessionId,
+            [FromRoute] string? sessionId,
             [FromBody] PlaystateRequest playstateRequest)
         {
             _sessionManager.SendPlaystateCommand(
@@ -213,8 +213,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/System/{command}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SendSystemCommand(
-            [FromRoute] string sessionId,
-            [FromRoute] string command)
+            [FromRoute] string? sessionId,
+            [FromRoute] string? command)
         {
             var name = command;
             if (Enum.TryParse(name, true, out GeneralCommandType commandType))
@@ -244,8 +244,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/Command/{Command}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SendGeneralCommand(
-            [FromRoute] string sessionId,
-            [FromRoute] string command)
+            [FromRoute] string? sessionId,
+            [FromRoute] string? command)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
 
@@ -270,7 +270,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/Command")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SendFullGeneralCommand(
-            [FromRoute] string sessionId,
+            [FromRoute] string? sessionId,
             [FromBody, Required] GeneralCommand command)
         {
             var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
@@ -303,9 +303,9 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/Message")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult SendMessageCommand(
-            [FromRoute] string sessionId,
-            [FromQuery] string text,
-            [FromQuery] string header,
+            [FromRoute] string? sessionId,
+            [FromQuery] string? text,
+            [FromQuery] string? header,
             [FromQuery] long? timeoutMs)
         {
             var command = new MessageCommand
@@ -330,7 +330,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/{sessionId}/User/{userId}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult AddUserToSession(
-            [FromRoute] string sessionId,
+            [FromRoute] string? sessionId,
             [FromRoute] Guid userId)
         {
             _sessionManager.AddAdditionalUser(sessionId, userId);
@@ -347,7 +347,7 @@ namespace Jellyfin.Api.Controllers
         [HttpDelete("/Sessions/{sessionId}/User/{userId}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult RemoveUserFromSession(
-            [FromRoute] string sessionId,
+            [FromRoute] string? sessionId,
             [FromRoute] Guid userId)
         {
             _sessionManager.RemoveAdditionalUser(sessionId, userId);
@@ -368,9 +368,9 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/Capabilities")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult PostCapabilities(
-            [FromQuery] string id,
-            [FromQuery] string playableMediaTypes,
-            [FromQuery] string supportedCommands,
+            [FromQuery] string? id,
+            [FromQuery] string? playableMediaTypes,
+            [FromQuery] string? supportedCommands,
             [FromQuery] bool supportsMediaControl,
             [FromQuery] bool supportsSync,
             [FromQuery] bool supportsPersistentIdentifier = true)
@@ -401,7 +401,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/Capabilities/Full")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult PostFullCapabilities(
-            [FromQuery] string id,
+            [FromQuery] string? id,
             [FromBody, Required] ClientCapabilities capabilities)
         {
             if (string.IsNullOrWhiteSpace(id))
@@ -424,8 +424,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Sessions/Viewing")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult ReportViewing(
-            [FromQuery] string sessionId,
-            [FromQuery] string itemId)
+            [FromQuery] string? sessionId,
+            [FromQuery] string? itemId)
         {
             string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
 

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

@@ -75,9 +75,9 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Configuration")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult UpdateInitialConfiguration(
-            [FromForm] string uiCulture,
-            [FromForm] string metadataCountryCode,
-            [FromForm] string preferredMetadataLanguage)
+            [FromForm] string? uiCulture,
+            [FromForm] string? metadataCountryCode,
+            [FromForm] string? preferredMetadataLanguage)
         {
             _config.Configuration.UICulture = uiCulture;
             _config.Configuration.MetadataCountryCode = metadataCountryCode;

+ 277 - 0
Jellyfin.Api/Controllers/StudiosController.cs

@@ -0,0 +1,277 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+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>
+    /// Studios controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class StudiosController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
+        private readonly IDtoService _dtoService;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="StudiosController"/> 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 StudiosController(
+            ILibraryManager libraryManager,
+            IUserManager userManager,
+            IDtoService dtoService)
+        {
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dtoService = dtoService;
+        }
+
+        /// <summary>
+        /// Gets all studios from a given item, folder, or the entire library.
+        /// </summary>
+        /// <param name="minCommunityRating">Optional filter by minimum community rating.</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="searchTerm">Optional. Search term.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
+        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</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>
+        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
+        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+        /// <param name="userId">User id.</param>
+        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+        /// <param name="enableImages">Optional, include image information in output.</param>
+        /// <param name="enableTotalRecordCount">Total record count.</param>
+        /// <response code="200">Studios returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the studios.</returns>
+        [HttpGet]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetStudios(
+            [FromQuery] double? minCommunityRating,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string searchTerm,
+            [FromQuery] string parentId,
+            [FromQuery] string fields,
+            [FromQuery] string excludeItemTypes,
+            [FromQuery] string includeItemTypes,
+            [FromQuery] string filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery] string mediaTypes,
+            [FromQuery] string genres,
+            [FromQuery] string genreIds,
+            [FromQuery] string officialRatings,
+            [FromQuery] string tags,
+            [FromQuery] string years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string enableImageTypes,
+            [FromQuery] string person,
+            [FromQuery] string personIds,
+            [FromQuery] string personTypes,
+            [FromQuery] string studios,
+            [FromQuery] string studioIds,
+            [FromQuery] Guid userId,
+            [FromQuery] string nameStartsWithOrGreater,
+            [FromQuery] string nameStartsWith,
+            [FromQuery] string nameLessThan,
+            [FromQuery] bool? enableImages = true,
+            [FromQuery] bool enableTotalRecordCount = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (!userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                StartIndex = startIndex,
+                Limit = limit,
+                IsFavorite = isFavorite,
+                NameLessThan = nameLessThan,
+                NameStartsWith = nameStartsWith,
+                NameStartsWithOrGreater = nameStartsWithOrGreater,
+                Tags = RequestHelpers.Split(tags, ',', true),
+                OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
+                Genres = RequestHelpers.Split(genres, ',', true),
+                GenreIds = RequestHelpers.GetGuids(genreIds),
+                StudioIds = RequestHelpers.GetGuids(studioIds),
+                Person = person,
+                PersonIds = RequestHelpers.GetGuids(personIds),
+                PersonTypes = RequestHelpers.Split(personTypes, ',', true),
+                Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+                MinCommunityRating = minCommunityRating,
+                DtoOptions = dtoOptions,
+                SearchTerm = searchTerm,
+                EnableTotalRecordCount = enableTotalRecordCount
+            };
+
+            if (!string.IsNullOrWhiteSpace(parentId))
+            {
+                if (parentItem is Folder)
+                {
+                    query.AncestorIds = new[] { new Guid(parentId) };
+                }
+                else
+                {
+                    query.ItemIds = new[] { new Guid(parentId) };
+                }
+            }
+
+            // Studios
+            if (!string.IsNullOrEmpty(studios))
+            {
+                query.StudioIds = studios.Split('|').Select(i =>
+                {
+                    try
+                    {
+                        return _libraryManager.GetStudio(i);
+                    }
+                    catch
+                    {
+                        return null;
+                    }
+                }).Where(i => i != null).Select(i => i!.Id)
+                    .ToArray();
+            }
+
+            foreach (var filter in RequestHelpers.GetFilters(filters))
+            {
+                switch (filter)
+                {
+                    case ItemFilter.Dislikes:
+                        query.IsLiked = false;
+                        break;
+                    case ItemFilter.IsFavorite:
+                        query.IsFavorite = true;
+                        break;
+                    case ItemFilter.IsFavoriteOrLikes:
+                        query.IsFavoriteOrLiked = true;
+                        break;
+                    case ItemFilter.IsFolder:
+                        query.IsFolder = true;
+                        break;
+                    case ItemFilter.IsNotFolder:
+                        query.IsFolder = false;
+                        break;
+                    case ItemFilter.IsPlayed:
+                        query.IsPlayed = true;
+                        break;
+                    case ItemFilter.IsResumable:
+                        query.IsResumable = true;
+                        break;
+                    case ItemFilter.IsUnplayed:
+                        query.IsPlayed = false;
+                        break;
+                    case ItemFilter.Likes:
+                        query.IsLiked = true;
+                        break;
+                }
+            }
+
+            var result = new QueryResult<(BaseItem, ItemCounts)>();
+            var dtos = result.Items.Select(i =>
+            {
+                var (baseItem, itemCounts) = i;
+                var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
+
+                if (!string.IsNullOrWhiteSpace(includeItemTypes))
+                {
+                    dto.ChildCount = itemCounts.ItemCount;
+                    dto.ProgramCount = itemCounts.ProgramCount;
+                    dto.SeriesCount = itemCounts.SeriesCount;
+                    dto.EpisodeCount = itemCounts.EpisodeCount;
+                    dto.MovieCount = itemCounts.MovieCount;
+                    dto.TrailerCount = itemCounts.TrailerCount;
+                    dto.AlbumCount = itemCounts.AlbumCount;
+                    dto.SongCount = itemCounts.SongCount;
+                    dto.ArtistCount = itemCounts.ArtistCount;
+                }
+
+                return dto;
+            });
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos.ToArray(),
+                TotalRecordCount = result.TotalRecordCount
+            };
+        }
+
+        /// <summary>
+        /// Gets a studio by name.
+        /// </summary>
+        /// <param name="name">Studio name.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <response code="200">Studio returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the studio.</returns>
+        [HttpGet("{name}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<BaseItemDto> GetStudio([FromRoute] string name, [FromQuery] Guid userId)
+        {
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            var item = _libraryManager.GetStudio(name);
+            if (!userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId);
+
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+    }
+}

+ 7 - 7
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -112,7 +112,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
             [FromRoute] Guid itemId,
-            [FromRoute] string language,
+            [FromRoute] string? language,
             [FromQuery] bool? isPerfectMatch)
         {
             var video = (Video)_libraryManager.GetItemById(itemId);
@@ -132,7 +132,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> DownloadRemoteSubtitles(
             [FromRoute] Guid itemId,
-            [FromRoute] string subtitleId)
+            [FromRoute] string? subtitleId)
         {
             var video = (Video)_libraryManager.GetItemById(itemId);
 
@@ -161,7 +161,7 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [Produces(MediaTypeNames.Application.Octet)]
-        public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string id)
+        public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string? id)
         {
             var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
 
@@ -186,9 +186,9 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public async Task<ActionResult> GetSubtitle(
             [FromRoute, Required] Guid itemId,
-            [FromRoute, Required] string mediaSourceId,
+            [FromRoute, Required] string? mediaSourceId,
             [FromRoute, Required] int index,
-            [FromRoute, Required] string format,
+            [FromRoute, Required] string? format,
             [FromQuery] long? endPositionTicks,
             [FromQuery] bool copyTimestamps,
             [FromQuery] bool addVttTimeMap,
@@ -254,7 +254,7 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> GetSubtitlePlaylist(
             [FromRoute] Guid itemId,
             [FromRoute] int index,
-            [FromRoute] string mediaSourceId,
+            [FromRoute] string? mediaSourceId,
             [FromQuery, Required] int segmentLength)
         {
             var item = (Video)_libraryManager.GetItemById(itemId);
@@ -324,7 +324,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns>
         private Task<Stream> EncodeSubtitles(
             Guid id,
-            string mediaSourceId,
+            string? mediaSourceId,
             int index,
             string format,
             long startPositionTicks,

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

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

@@ -0,0 +1,377 @@
+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>
+        /// <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)]
+        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)
+        {
+            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;
+        }
+    }
+}

+ 5 - 8
Jellyfin.Api/Controllers/UserController.cs

@@ -68,17 +68,14 @@ namespace Jellyfin.Api.Controllers
         /// </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)
+            [FromQuery] bool? isDisabled)
         {
             var users = Get(isHidden, isDisabled, false, false);
             return Ok(users);
@@ -167,8 +164,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
             [FromRoute, Required] Guid userId,
-            [FromQuery, BindRequired] string pw,
-            [FromQuery, BindRequired] string password)
+            [FromQuery, BindRequired] string? pw,
+            [FromQuery, BindRequired] string? password)
         {
             var user = _userManager.GetUserById(userId);
 
@@ -486,7 +483,7 @@ namespace Jellyfin.Api.Controllers
         /// <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)
+        public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string? enteredUsername)
         {
             var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress)
                           || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString());
@@ -504,7 +501,7 @@ namespace Jellyfin.Api.Controllers
         /// <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)
+        public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string? pin)
         {
             var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false);
             return result;

+ 391 - 0
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -0,0 +1,391 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// User library controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class UserLibraryController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly IUserDataManager _userDataRepository;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IDtoService _dtoService;
+        private readonly IUserViewManager _userViewManager;
+        private readonly IFileSystem _fileSystem;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="UserLibraryController"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> 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="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        public UserLibraryController(
+            IUserManager userManager,
+            IUserDataManager userDataRepository,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            IUserViewManager userViewManager,
+            IFileSystem fileSystem)
+        {
+            _userManager = userManager;
+            _userDataRepository = userDataRepository;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+            _userViewManager = userViewManager;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        /// Gets an item from a user's library.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Item returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the d item.</returns>
+        [HttpGet("/Users/{userId}/Items/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+        }
+
+        /// <summary>
+        /// Gets the root folder from a user's library.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <response code="200">Root folder returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
+        [HttpGet("/Users/{userId}/Items/Root")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<BaseItemDto> GetRootFolder([FromRoute] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+            var item = _libraryManager.GetUserRootFolder();
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+        }
+
+        /// <summary>
+        /// Gets intros to play before the main media item plays.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Intros returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
+        [HttpGet("/Users/{userId}/Items/{itemId}/Intros")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos,
+                TotalRecordCount = dtos.Length
+            };
+        }
+
+        /// <summary>
+        /// Marks an item as a favorite.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Item marked as favorite.</response>
+        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpPost("/Users/{userId}/FavoriteItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            return MarkFavorite(userId, itemId, true);
+        }
+
+        /// <summary>
+        /// Unmarks item as a favorite.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Item unmarked as favorite.</response>
+        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpDelete("/Users/{userId}/FavoriteItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            return MarkFavorite(userId, itemId, false);
+        }
+
+        /// <summary>
+        /// Deletes a user's saved personal rating for an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Personal rating removed.</response>
+        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpDelete("/Users/{userId}/Items/{itemId}/Rating")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            return UpdateUserItemRatingInternal(userId, itemId, null);
+        }
+
+        /// <summary>
+        /// Updates a user's rating for an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
+        /// <response code="200">Item rating updated.</response>
+        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpPost("/Users/{userId}/Items/{itemId}/Rating")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId, [FromQuery] bool likes)
+        {
+            return UpdateUserItemRatingInternal(userId, itemId, likes);
+        }
+
+        /// <summary>
+        /// Gets local trailers for an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
+        /// <returns>The items local trailers.</returns>
+        [HttpGet("/Users/{userId}/Items/{itemId}/LocalTrailers")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var dtosExtras = item.GetExtras(new[] { ExtraType.Trailer })
+                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
+                .ToArray();
+
+            if (item is IHasTrailers hasTrailers)
+            {
+                var trailers = hasTrailers.GetTrailers();
+                var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item);
+                var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count];
+                dtosExtras.CopyTo(allTrailers, 0);
+                dtosTrailers.CopyTo(allTrailers, dtosExtras.Length);
+                return allTrailers;
+            }
+
+            return dtosExtras;
+        }
+
+        /// <summary>
+        /// Gets special features for an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Special features returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the special features.</returns>
+        [HttpGet("/Users/{userId}/Items/{itemId}/SpecialFeatures")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty)
+                ? _libraryManager.GetUserRootFolder()
+                : _libraryManager.GetItemById(itemId);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+            return Ok(item
+                .GetExtras(BaseItem.DisplayExtraTypes)
+                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
+        }
+
+        /// <summary>
+        /// Gets latest media.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+        /// <param name="isPlayed">Filter by items that are played, or not.</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="limit">Return item limit.</param>
+        /// <param name="groupItems">Whether or not to group items into a parent container.</param>
+        /// <response code="200">Latest media returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
+        [HttpGet("/Users/{userId}/Items/Latest")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
+            [FromRoute] Guid userId,
+            [FromQuery] Guid parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] bool? isPlayed,
+            [FromQuery] bool? enableImages,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int limit = 20,
+            [FromQuery] bool groupItems = true)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            if (!isPlayed.HasValue)
+            {
+                if (user.HidePlayedInLatest)
+                {
+                    isPlayed = false;
+                }
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            var list = _userViewManager.GetLatestItems(
+                new LatestItemsQuery
+                {
+                    GroupItems = groupItems,
+                    IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+                    IsPlayed = isPlayed,
+                    Limit = limit,
+                    ParentId = parentId,
+                    UserId = userId,
+                }, dtoOptions);
+
+            var dtos = list.Select(i =>
+            {
+                var item = i.Item2[0];
+                var childCount = 0;
+
+                if (i.Item1 != null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum))
+                {
+                    item = i.Item1;
+                    childCount = i.Item2.Count;
+                }
+
+                var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
+
+                dto.ChildCount = childCount;
+
+                return dto;
+            });
+
+            return Ok(dtos);
+        }
+
+        private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
+        {
+            if (item is Person)
+            {
+                var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
+                var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
+
+                if (!hasMetdata)
+                {
+                    var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+                    {
+                        MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+                        ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+                        ForceSave = performFullRefresh
+                    };
+
+                    await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Marks the favorite.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param>
+        private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
+
+            // Get the user data for this item
+            var data = _userDataRepository.GetUserData(user, item);
+
+            // Set favorite status
+            data.IsFavorite = isFavorite;
+
+            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+
+            return _userDataRepository.GetUserDataDto(item, user);
+        }
+
+        /// <summary>
+        /// Updates the user item rating.
+        /// </summary>
+        /// <param name="userId">The user id.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="likes">if set to <c>true</c> [likes].</param>
+        private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes)
+        {
+            var user = _userManager.GetUserById(userId);
+
+            var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
+
+            // Get the user data for this item
+            var data = _userDataRepository.GetUserData(user, item);
+
+            data.Likes = likes;
+
+            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+
+            return _userDataRepository.GetUserDataDto(item, user);
+        }
+    }
+}

+ 148 - 0
Jellyfin.Api/Controllers/UserViewsController.cs

@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.UserViewDtos;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Library;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// User views controller.
+    /// </summary>
+    public class UserViewsController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly IUserViewManager _userViewManager;
+        private readonly IDtoService _dtoService;
+        private readonly IAuthorizationContext _authContext;
+        private readonly ILibraryManager _libraryManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="UserViewsController"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
+        /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        public UserViewsController(
+            IUserManager userManager,
+            IUserViewManager userViewManager,
+            IDtoService dtoService,
+            IAuthorizationContext authContext,
+            ILibraryManager libraryManager)
+        {
+            _userManager = userManager;
+            _userViewManager = userViewManager;
+            _dtoService = dtoService;
+            _authContext = authContext;
+            _libraryManager = libraryManager;
+        }
+
+        /// <summary>
+        /// Get user views.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
+        /// <param name="includeHidden">Whether or not to include hidden content.</param>
+        /// <param name="presetViews">Preset views.</param>
+        /// <response code="200">User views returned.</response>
+        /// <returns>An <see cref="OkResult"/> containing the user views.</returns>
+        [HttpGet("/Users/{userId}/Views")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetUserViews(
+            [FromRoute] Guid userId,
+            [FromQuery] bool? includeExternalContent,
+            [FromQuery] bool includeHidden,
+            [FromQuery] string? presetViews)
+        {
+            var query = new UserViewQuery
+            {
+                UserId = userId,
+                IncludeHidden = includeHidden
+            };
+
+            if (includeExternalContent.HasValue)
+            {
+                query.IncludeExternalContent = includeExternalContent.Value;
+            }
+
+            if (!string.IsNullOrWhiteSpace(presetViews))
+            {
+                query.PresetViews = RequestHelpers.Split(presetViews, ',', true);
+            }
+
+            var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;
+            if (app.IndexOf("emby rt", StringComparison.OrdinalIgnoreCase) != -1)
+            {
+                query.PresetViews = new[] { CollectionType.Movies, CollectionType.TvShows };
+            }
+
+            var folders = _userViewManager.GetUserViews(query);
+
+            var dtoOptions = new DtoOptions().AddClientFields(Request);
+            var fields = dtoOptions.Fields.ToList();
+
+            fields.Add(ItemFields.PrimaryImageAspectRatio);
+            fields.Add(ItemFields.DisplayPreferencesId);
+            fields.Remove(ItemFields.BasicSyncInfo);
+            dtoOptions.Fields = fields.ToArray();
+
+            var user = _userManager.GetUserById(userId);
+
+            var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
+                .ToArray();
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos,
+                TotalRecordCount = dtos.Length
+            };
+        }
+
+        /// <summary>
+        /// Get user view grouping options.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <response code="200">User view grouping options returned.</response>
+        /// <response code="404">User not found.</response>
+        /// <returns>
+        /// An <see cref="OkResult"/> containing the user view grouping options
+        /// or a <see cref="NotFoundResult"/> if user not found.
+        /// </returns>
+        [HttpGet("/Users/{userId}/GroupingOptions")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute] Guid userId)
+        {
+            var user = _userManager.GetUserById(userId);
+            if (user == null)
+            {
+                return NotFound();
+            }
+
+            return Ok(_libraryManager.GetUserRootFolder()
+                .GetChildren(user, true)
+                .OfType<Folder>()
+                .Where(UserView.IsEligibleForGrouping)
+                .Select(i => new SpecialViewOptionDto
+                {
+                    Name = i.Name,
+                    Id = i.Id.ToString("N", CultureInfo.InvariantCulture)
+                })
+                .OrderBy(i => i.Name));
+        }
+    }
+}

+ 1 - 1
Jellyfin.Api/Controllers/VideoAttachmentsController.cs

@@ -50,7 +50,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult<FileStreamResult>> GetAttachment(
             [FromRoute] Guid videoId,
-            [FromRoute] string mediaSourceId,
+            [FromRoute] string? mediaSourceId,
             [FromRoute] int index)
         {
             try

+ 1 - 1
Jellyfin.Api/Controllers/VideosController.cs

@@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status400BadRequest)]
-        public ActionResult MergeVersions([FromQuery] string itemIds)
+        public ActionResult MergeVersions([FromQuery] string? itemIds)
         {
             var items = RequestHelpers.Split(itemIds, ',', true)
                 .Select(i => _libraryManager.GetItemById(i))

+ 231 - 0
Jellyfin.Api/Controllers/YearsController.cs

@@ -0,0 +1,231 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Years controller.
+    /// </summary>
+    public class YearsController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
+        private readonly IDtoService _dtoService;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="YearsController"/> 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 YearsController(
+            ILibraryManager libraryManager,
+            IUserManager userManager,
+            IDtoService dtoService)
+        {
+            _libraryManager = libraryManager;
+            _userManager = userManager;
+            _dtoService = dtoService;
+        }
+
+        /// <summary>
+        /// Get years.
+        /// </summary>
+        /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param>
+        /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</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>
+        /// <param name="userId">User Id.</param>
+        /// <param name="recursive">Search recursively.</param>
+        /// <param name="enableImages">Optional. Include image information in output.</param>
+        /// <response code="200">Year query returned.</response>
+        /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns>
+        [HttpGet]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetYears(
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] string? sortOrder,
+            [FromQuery] string? parentId,
+            [FromQuery] string? fields,
+            [FromQuery] string? excludeItemTypes,
+            [FromQuery] string? includeItemTypes,
+            [FromQuery] string? mediaTypes,
+            [FromQuery] string? sortBy,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery] string? enableImageTypes,
+            [FromQuery] Guid userId,
+            [FromQuery] bool recursive = true,
+            [FromQuery] bool? enableImages = true)
+        {
+            var dtoOptions = new DtoOptions()
+                .AddItemFields(fields)
+                .AddClientFields(Request)
+                .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+            User? user = null;
+            BaseItem parentItem;
+
+            if (!userId.Equals(Guid.Empty))
+            {
+                user = _userManager.GetUserById(userId);
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
+            }
+            else
+            {
+                parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
+            }
+
+            IList<BaseItem> items;
+
+            var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+            var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+            var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+            var query = new InternalItemsQuery(user)
+            {
+                ExcludeItemTypes = excludeItemTypesArr,
+                IncludeItemTypes = includeItemTypesArr,
+                MediaTypes = mediaTypesArr,
+                DtoOptions = dtoOptions
+            };
+
+            bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr);
+
+            if (parentItem.IsFolder)
+            {
+                var folder = (Folder)parentItem;
+
+                if (!userId.Equals(Guid.Empty))
+                {
+                    items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList();
+                }
+                else
+                {
+                    items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList();
+                }
+            }
+            else
+            {
+                items = new[] { parentItem }.Where(Filter).ToList();
+            }
+
+            var extractedItems = GetAllItems(items);
+
+            var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder));
+
+            var ibnItemsArray = filteredItems.ToList();
+
+            IEnumerable<BaseItem> ibnItems = ibnItemsArray;
+
+            var result = new QueryResult<BaseItemDto> { TotalRecordCount = ibnItemsArray.Count };
+
+            if (startIndex.HasValue || limit.HasValue)
+            {
+                if (startIndex.HasValue)
+                {
+                    ibnItems = ibnItems.Skip(startIndex.Value);
+                }
+
+                if (limit.HasValue)
+                {
+                    ibnItems = ibnItems.Take(limit.Value);
+                }
+            }
+
+            var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>()));
+
+            var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user));
+
+            result.Items = dtos.Where(i => i != null).ToArray();
+
+            return result;
+        }
+
+        /// <summary>
+        /// Gets a year.
+        /// </summary>
+        /// <param name="year">The year.</param>
+        /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+        /// <response code="200">Year returned.</response>
+        /// <response code="404">Year not found.</response>
+        /// <returns>
+        /// An <see cref="OkResult"/> containing the year,
+        /// or a <see cref="NotFoundResult"/> if year not found.
+        /// </returns>
+        [HttpGet("{year}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<BaseItemDto> GetYear([FromRoute] int year, [FromQuery] Guid userId)
+        {
+            var item = _libraryManager.GetYear(year);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            var dtoOptions = new DtoOptions()
+                .AddClientFields(Request);
+
+            if (!userId.Equals(Guid.Empty))
+            {
+                var user = _userManager.GetUserById(userId);
+                return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+            }
+
+            return _dtoService.GetBaseItemDto(item, dtoOptions);
+        }
+
+        private bool FilterItem(BaseItem f, IReadOnlyCollection<string> excludeItemTypes, IReadOnlyCollection<string> includeItemTypes, IReadOnlyCollection<string> mediaTypes)
+        {
+            // Exclude item types
+            if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase))
+            {
+                return false;
+            }
+
+            // Include item types
+            if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase))
+            {
+                return false;
+            }
+
+            // Include MediaTypes
+            if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
+            {
+                return false;
+            }
+
+            return true;
+        }
+
+        private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items)
+        {
+            return items
+                .Select(i => i.ProductionYear ?? 0)
+                .Where(i => i > 0)
+                .Distinct()
+                .Select(year => _libraryManager.GetYear(year));
+        }
+    }
+}

+ 25 - 30
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -78,35 +78,13 @@ namespace Jellyfin.Api.Helpers
         }
 
         /// <summary>
-        /// Gets the item fields.
+        /// Get Guid array from string.
         /// </summary>
-        /// <param name="fields">The item field string.</param>
-        /// <returns>Array of parsed item fields.</returns>
-        internal static ItemFields[] GetItemFields(string fields)
-        {
-            if (string.IsNullOrEmpty(fields))
-            {
-                return Array.Empty<ItemFields>();
-            }
-
-            return Split(fields, ',', true)
-                .Select(v =>
-                {
-                    if (Enum.TryParse(v, true, out ItemFields value))
-                    {
-                        return (ItemFields?)value;
-                    }
-
-                    return null;
-                })
-                .Where(i => i.HasValue)
-                .Select(i => i!.Value)
-                .ToArray();
-        }
-
+        /// <param name="value">String value.</param>
+        /// <returns>Guid array.</returns>
         internal static Guid[] GetGuids(string? value)
         {
-            if (string.IsNullOrEmpty(value))
+            if (value == null)
             {
                 return Array.Empty<Guid>();
             }
@@ -116,16 +94,20 @@ namespace Jellyfin.Api.Helpers
                 .ToArray();
         }
 
+        /// <summary>
+        /// Get orderby.
+        /// </summary>
+        /// <param name="sortBy">Sort by.</param>
+        /// <param name="requestedSortOrder">Sort order.</param>
+        /// <returns>Resulting order by.</returns>
         internal static ValueTuple<string, SortOrder>[] GetOrderBy(string? sortBy, string? requestedSortOrder)
         {
-            var val = sortBy;
-
-            if (string.IsNullOrEmpty(val))
+            if (string.IsNullOrEmpty(sortBy))
             {
                 return Array.Empty<ValueTuple<string, SortOrder>>();
             }
 
-            var vals = val.Split(',');
+            var vals = sortBy.Split(',');
             if (string.IsNullOrWhiteSpace(requestedSortOrder))
             {
                 requestedSortOrder = "Ascending";
@@ -149,5 +131,18 @@ namespace Jellyfin.Api.Helpers
 
             return result;
         }
+
+        /// <summary>
+        /// Gets the filters.
+        /// </summary>
+        /// <param name="filters">The filter string.</param>
+        /// <returns>IEnumerable{ItemFilter}.</returns>
+        internal static ItemFilter[] GetFilters(string filters)
+        {
+            return string.IsNullOrEmpty(filters)
+                ? Array.Empty<ItemFilter>()
+                : Split(filters, ',', true)
+                    .Select(v => Enum.Parse<ItemFilter>(v, true)).ToArray();
+        }
     }
 }

+ 182 - 0
Jellyfin.Api/Helpers/SimilarItemsHelper.cs

@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// The similar items helper class.
+    /// </summary>
+    public static class SimilarItemsHelper
+    {
+        internal static QueryResult<BaseItemDto> GetSimilarItemsResult(
+            DtoOptions dtoOptions,
+            IUserManager userManager,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            Guid userId,
+            string id,
+            string? excludeArtistIds,
+            int? limit,
+            Type[] includeTypes,
+            Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
+        {
+            var user = !userId.Equals(Guid.Empty) ? userManager.GetUserById(userId) : null;
+
+            var item = string.IsNullOrEmpty(id) ?
+                (!userId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() :
+                libraryManager.RootFolder) : libraryManager.GetItemById(id);
+
+            var query = new InternalItemsQuery(user)
+            {
+                IncludeItemTypes = includeTypes.Select(i => i.Name).ToArray(),
+                Recursive = true,
+                DtoOptions = dtoOptions
+            };
+
+            query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+
+            var inputItems = libraryManager.GetItemList(query);
+
+            var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore)
+                .ToList();
+
+            var returnItems = items;
+
+            if (limit.HasValue)
+            {
+                returnItems = returnItems.Take(limit.Value).ToList();
+            }
+
+            var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
+
+            return new QueryResult<BaseItemDto>
+            {
+                Items = dtos,
+                TotalRecordCount = items.Count
+            };
+        }
+
+        /// <summary>
+        /// Gets the similaritems.
+        /// </summary>
+        /// <param name="item">The item.</param>
+        /// <param name="libraryManager">The library manager.</param>
+        /// <param name="inputItems">The input items.</param>
+        /// <param name="getSimilarityScore">The get similarity score.</param>
+        /// <returns>IEnumerable{BaseItem}.</returns>
+        private static IEnumerable<BaseItem> GetSimilaritems(
+            BaseItem item,
+            ILibraryManager libraryManager,
+            IEnumerable<BaseItem> inputItems,
+            Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
+        {
+            var itemId = item.Id;
+            inputItems = inputItems.Where(i => i.Id != itemId);
+            var itemPeople = libraryManager.GetPeople(item);
+            var allPeople = libraryManager.GetPeople(new InternalPeopleQuery
+            {
+                AppearsInItemId = item.Id
+            });
+
+            return inputItems.Select(i => new Tuple<BaseItem, int>(i, getSimilarityScore(item, itemPeople, allPeople, i)))
+                .Where(i => i.Item2 > 2)
+                .OrderByDescending(i => i.Item2)
+                .Select(i => i.Item1);
+        }
+
+        private static IEnumerable<string> GetTags(BaseItem item)
+        {
+            return item.Tags;
+        }
+
+        /// <summary>
+        /// Gets the similiarity score.
+        /// </summary>
+        /// <param name="item1">The item1.</param>
+        /// <param name="item1People">The item1 people.</param>
+        /// <param name="allPeople">All people.</param>
+        /// <param name="item2">The item2.</param>
+        /// <returns>System.Int32.</returns>
+        internal static int GetSimiliarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2)
+        {
+            var points = 0;
+
+            if (!string.IsNullOrEmpty(item1.OfficialRating) && string.Equals(item1.OfficialRating, item2.OfficialRating, StringComparison.OrdinalIgnoreCase))
+            {
+                points += 10;
+            }
+
+            // Find common genres
+            points += item1.Genres.Where(i => item2.Genres.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10);
+
+            // Find common tags
+            points += GetTags(item1).Where(i => GetTags(item2).Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10);
+
+            // Find common studios
+            points += item1.Studios.Where(i => item2.Studios.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 3);
+
+            var item2PeopleNames = allPeople.Where(i => i.ItemId == item2.Id)
+                .Select(i => i.Name)
+                .Where(i => !string.IsNullOrWhiteSpace(i))
+                .DistinctNames()
+                .ToDictionary(i => i, StringComparer.OrdinalIgnoreCase);
+
+            points += item1People.Where(i => item2PeopleNames.ContainsKey(i.Name)).Sum(i =>
+            {
+                if (string.Equals(i.Type, PersonType.Director, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Director, StringComparison.OrdinalIgnoreCase))
+                {
+                    return 5;
+                }
+
+                if (string.Equals(i.Type, PersonType.Actor, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Actor, StringComparison.OrdinalIgnoreCase))
+                {
+                    return 3;
+                }
+
+                if (string.Equals(i.Type, PersonType.Composer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Composer, StringComparison.OrdinalIgnoreCase))
+                {
+                    return 3;
+                }
+
+                if (string.Equals(i.Type, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase))
+                {
+                    return 3;
+                }
+
+                if (string.Equals(i.Type, PersonType.Writer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Writer, StringComparison.OrdinalIgnoreCase))
+                {
+                    return 2;
+                }
+
+                return 1;
+            });
+
+            if (item1.ProductionYear.HasValue && item2.ProductionYear.HasValue)
+            {
+                var diff = Math.Abs(item1.ProductionYear.Value - item2.ProductionYear.Value);
+
+                // Add if they came out within the same decade
+                if (diff < 10)
+                {
+                    points += 2;
+                }
+
+                // And more if within five years
+                if (diff < 5)
+                {
+                    points += 2;
+                }
+            }
+
+            return points;
+        }
+    }
+}

+ 354 - 0
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -0,0 +1,354 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// Transcoding job helpers.
+    /// </summary>
+    public class TranscodingJobHelper
+    {
+        /// <summary>
+        /// The active transcoding jobs.
+        /// </summary>
+        private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>();
+
+        /// <summary>
+        /// The transcoding locks.
+        /// </summary>
+        private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>();
+
+        private readonly ILogger<TranscodingJobHelper> _logger;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IFileSystem _fileSystem;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        public TranscodingJobHelper(
+            ILogger<TranscodingJobHelper> logger,
+            IMediaSourceManager mediaSourceManager,
+            IFileSystem fileSystem)
+        {
+            _logger = logger;
+            _mediaSourceManager = mediaSourceManager;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        /// Get transcoding job.
+        /// </summary>
+        /// <param name="playSessionId">Playback session id.</param>
+        /// <returns>The transcoding job.</returns>
+        public TranscodingJobDto GetTranscodingJob(string playSessionId)
+        {
+            lock (_activeTranscodingJobs)
+            {
+                return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
+            }
+        }
+
+        /// <summary>
+        /// Ping transcoding job.
+        /// </summary>
+        /// <param name="playSessionId">Play session id.</param>
+        /// <param name="isUserPaused">Is user paused.</param>
+        /// <exception cref="ArgumentNullException">Play session id is null.</exception>
+        public void PingTranscodingJob(string playSessionId, bool? isUserPaused)
+        {
+            if (string.IsNullOrEmpty(playSessionId))
+            {
+                throw new ArgumentNullException(nameof(playSessionId));
+            }
+
+            _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused);
+
+            List<TranscodingJobDto> jobs;
+
+            lock (_activeTranscodingJobs)
+            {
+                // This is really only needed for HLS.
+                // Progressive streams can stop on their own reliably
+                jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
+            }
+
+            foreach (var job in jobs)
+            {
+                if (isUserPaused.HasValue)
+                {
+                    _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id);
+                    job.IsUserPaused = isUserPaused.Value;
+                }
+
+                PingTimer(job, true);
+            }
+        }
+
+        private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn)
+        {
+            if (job.HasExited)
+            {
+                job.StopKillTimer();
+                return;
+            }
+
+            var timerDuration = 10000;
+
+            if (job.Type != TranscodingJobType.Progressive)
+            {
+                timerDuration = 60000;
+            }
+
+            job.PingTimeout = timerDuration;
+            job.LastPingDate = DateTime.UtcNow;
+
+            // Don't start the timer for playback checkins with progressive streaming
+            if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn)
+            {
+                job.StartKillTimer(OnTranscodeKillTimerStopped);
+            }
+            else
+            {
+                job.ChangeKillTimerIfStarted();
+            }
+        }
+
+        /// <summary>
+        /// Called when [transcode kill timer stopped].
+        /// </summary>
+        /// <param name="state">The state.</param>
+        private async void OnTranscodeKillTimerStopped(object state)
+        {
+            var job = (TranscodingJobDto)state;
+
+            if (!job.HasExited && job.Type != TranscodingJobType.Progressive)
+            {
+                var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds;
+
+                if (timeSinceLastPing < job.PingTimeout)
+                {
+                    job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout);
+                    return;
+                }
+            }
+
+            _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
+
+            await KillTranscodingJob(job, true, path => true).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Kills the single transcoding job.
+        /// </summary>
+        /// <param name="deviceId">The device id.</param>
+        /// <param name="playSessionId">The play session identifier.</param>
+        /// <param name="deleteFiles">The delete files.</param>
+        /// <returns>Task.</returns>
+        public Task KillTranscodingJobs(string deviceId, string playSessionId, Func<string, bool> deleteFiles)
+        {
+            return KillTranscodingJobs(
+                j => string.IsNullOrWhiteSpace(playSessionId)
+                    ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase)
+                    : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), deleteFiles);
+        }
+
+        /// <summary>
+        /// Kills the transcoding jobs.
+        /// </summary>
+        /// <param name="killJob">The kill job.</param>
+        /// <param name="deleteFiles">The delete files.</param>
+        /// <returns>Task.</returns>
+        private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles)
+        {
+            var jobs = new List<TranscodingJobDto>();
+
+            lock (_activeTranscodingJobs)
+            {
+                // This is really only needed for HLS.
+                // Progressive streams can stop on their own reliably
+                jobs.AddRange(_activeTranscodingJobs.Where(killJob));
+            }
+
+            if (jobs.Count == 0)
+            {
+                return Task.CompletedTask;
+            }
+
+            IEnumerable<Task> GetKillJobs()
+            {
+                foreach (var job in jobs)
+                {
+                    yield return KillTranscodingJob(job, false, deleteFiles);
+                }
+            }
+
+            return Task.WhenAll(GetKillJobs());
+        }
+
+        /// <summary>
+        /// Kills the transcoding job.
+        /// </summary>
+        /// <param name="job">The job.</param>
+        /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param>
+        /// <param name="delete">The delete.</param>
+        private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete)
+        {
+            job.DisposeKillTimer();
+
+            _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
+
+            lock (_activeTranscodingJobs)
+            {
+                _activeTranscodingJobs.Remove(job);
+
+                if (!job.CancellationTokenSource!.IsCancellationRequested)
+                {
+                    job.CancellationTokenSource.Cancel();
+                }
+            }
+
+            lock (_transcodingLocks)
+            {
+                _transcodingLocks.Remove(job.Path!);
+            }
+
+            lock (job.ProcessLock!)
+            {
+                job.TranscodingThrottler?.Stop().GetAwaiter().GetResult();
+
+                var process = job.Process;
+
+                var hasExited = job.HasExited;
+
+                if (!hasExited)
+                {
+                    try
+                    {
+                        _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path);
+
+                        process!.StandardInput.WriteLine("q");
+
+                        // Need to wait because killing is asynchronous
+                        if (!process.WaitForExit(5000))
+                        {
+                            _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path);
+                            process.Kill();
+                        }
+                    }
+                    catch (InvalidOperationException)
+                    {
+                    }
+                }
+            }
+
+            if (delete(job.Path!))
+            {
+                await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
+            }
+
+            if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
+            {
+                try
+                {
+                    await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error closing live stream for {Path}", job.Path);
+                }
+            }
+        }
+
+        private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs)
+        {
+            if (retryCount >= 10)
+            {
+                return;
+            }
+
+            _logger.LogInformation("Deleting partial stream file(s) {Path}", path);
+
+            await Task.Delay(delayMs).ConfigureAwait(false);
+
+            try
+            {
+                if (jobType == TranscodingJobType.Progressive)
+                {
+                    DeleteProgressivePartialStreamFiles(path);
+                }
+                else
+                {
+                    DeleteHlsPartialStreamFiles(path);
+                }
+            }
+            catch (IOException ex)
+            {
+                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+
+                await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+            }
+        }
+
+        /// <summary>
+        /// Deletes the progressive partial stream files.
+        /// </summary>
+        /// <param name="outputFilePath">The output file path.</param>
+        private void DeleteProgressivePartialStreamFiles(string outputFilePath)
+        {
+            if (File.Exists(outputFilePath))
+            {
+                _fileSystem.DeleteFile(outputFilePath);
+            }
+        }
+
+        /// <summary>
+        /// Deletes the HLS partial stream files.
+        /// </summary>
+        /// <param name="outputFilePath">The output file path.</param>
+        private void DeleteHlsPartialStreamFiles(string outputFilePath)
+        {
+            var directory = Path.GetDirectoryName(outputFilePath);
+            var name = Path.GetFileNameWithoutExtension(outputFilePath);
+
+            var filesToDelete = _fileSystem.GetFilePaths(directory)
+                .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1);
+
+            List<Exception>? exs = null;
+            foreach (var file in filesToDelete)
+            {
+                try
+                {
+                    _logger.LogDebug("Deleting HLS file {0}", file);
+                    _fileSystem.DeleteFile(file);
+                }
+                catch (IOException ex)
+                {
+                    (exs ??= new List<Exception>(4)).Add(ex);
+                    _logger.LogError(ex, "Error deleting HLS file {Path}", file);
+                }
+            }
+
+            if (exs != null)
+            {
+                throw new AggregateException("Error deleting HLS files", exs);
+            }
+        }
+    }
+}

+ 18 - 0
Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Api.Models.LibraryDtos
+{
+    /// <summary>
+    /// Library option info dto.
+    /// </summary>
+    public class LibraryOptionInfoDto
+    {
+        /// <summary>
+        /// Gets or sets name.
+        /// </summary>
+        public string? Name { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether default enabled.
+        /// </summary>
+        public bool DefaultEnabled { get; set; }
+    }
+}

+ 34 - 0
Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs

@@ -0,0 +1,34 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Jellyfin.Api.Models.LibraryDtos
+{
+    /// <summary>
+    /// Library options result dto.
+    /// </summary>
+    public class LibraryOptionsResultDto
+    {
+        /// <summary>
+        /// Gets or sets the metadata savers.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataSavers", Justification = "Imported from ServiceStack")]
+        public LibraryOptionInfoDto[] MetadataSavers { get; set; } = null!;
+
+        /// <summary>
+        /// Gets or sets the metadata readers.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataReaders", Justification = "Imported from ServiceStack")]
+        public LibraryOptionInfoDto[] MetadataReaders { get; set; } = null!;
+
+        /// <summary>
+        /// Gets or sets the subtitle fetchers.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SubtitleFetchers", Justification = "Imported from ServiceStack")]
+        public LibraryOptionInfoDto[] SubtitleFetchers { get; set; } = null!;
+
+        /// <summary>
+        /// Gets or sets the type options.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "TypeOptions", Justification = "Imported from ServiceStack")]
+        public LibraryTypeOptionsDto[] TypeOptions { get; set; } = null!;
+    }
+}

+ 41 - 0
Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs

@@ -0,0 +1,41 @@
+using System.Diagnostics.CodeAnalysis;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+
+namespace Jellyfin.Api.Models.LibraryDtos
+{
+    /// <summary>
+    /// Library type options dto.
+    /// </summary>
+    public class LibraryTypeOptionsDto
+    {
+        /// <summary>
+        /// Gets or sets the type.
+        /// </summary>
+        public string? Type { get; set; }
+
+        /// <summary>
+        /// Gets or sets the metadata fetchers.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataFetchers", Justification = "Imported from ServiceStack")]
+        public LibraryOptionInfoDto[] MetadataFetchers { get; set; } = null!;
+
+        /// <summary>
+        /// Gets or sets the image fetchers.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "ImageFetchers", Justification = "Imported from ServiceStack")]
+        public LibraryOptionInfoDto[] ImageFetchers { get; set; } = null!;
+
+        /// <summary>
+        /// Gets or sets the supported image types.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SupportedImageTypes", Justification = "Imported from ServiceStack")]
+        public ImageType[] SupportedImageTypes { get; set; } = null!;
+
+        /// <summary>
+        /// Gets or sets the default image options.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "DefaultImageOptions", Justification = "Imported from ServiceStack")]
+        public ImageOption[] DefaultImageOptions { get; set; } = null!;
+    }
+}

+ 19 - 0
Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs

@@ -0,0 +1,19 @@
+namespace Jellyfin.Api.Models.LibraryDtos
+{
+    /// <summary>
+    /// Media Update Info Dto.
+    /// </summary>
+    public class MediaUpdateInfoDto
+    {
+        /// <summary>
+        /// Gets or sets media path.
+        /// </summary>
+        public string? Path { get; set; }
+
+        /// <summary>
+        /// Gets or sets media update type.
+        /// Created, Modified, Deleted.
+        /// </summary>
+        public string? UpdateType { get; set; }
+    }
+}

+ 256 - 0
Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs

@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dto;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Models.PlaybackDtos
+{
+    /// <summary>
+    /// Class TranscodingJob.
+    /// </summary>
+    public class TranscodingJobDto
+    {
+        /// <summary>
+        /// The process lock.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")]
+        public readonly object ProcessLock = new object();
+
+        /// <summary>
+        /// Timer lock.
+        /// </summary>
+        private readonly object _timerLock = new object();
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TranscodingJobDto"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param>
+        public TranscodingJobDto(ILogger<TranscodingJobDto> logger)
+        {
+            Logger = logger;
+        }
+
+        /// <summary>
+        /// Gets or sets the play session identifier.
+        /// </summary>
+        /// <value>The play session identifier.</value>
+        public string? PlaySessionId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the live stream identifier.
+        /// </summary>
+        /// <value>The live stream identifier.</value>
+        public string? LiveStreamId { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether is live output.
+        /// </summary>
+        public bool IsLiveOutput { get; set; }
+
+        /// <summary>
+        /// Gets or sets the path.
+        /// </summary>
+        /// <value>The path.</value>
+        public MediaSourceInfo? MediaSource { get; set; }
+
+        /// <summary>
+        /// Gets or sets path.
+        /// </summary>
+        public string? Path { get; set; }
+
+        /// <summary>
+        /// Gets or sets the type.
+        /// </summary>
+        /// <value>The type.</value>
+        public TranscodingJobType Type { get; set; }
+
+        /// <summary>
+        /// Gets or sets the process.
+        /// </summary>
+        /// <value>The process.</value>
+        public Process? Process { get; set; }
+
+        /// <summary>
+        /// Gets logger.
+        /// </summary>
+        public ILogger<TranscodingJobDto> Logger { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the active request count.
+        /// </summary>
+        /// <value>The active request count.</value>
+        public int ActiveRequestCount { get; set; }
+
+        /// <summary>
+        /// Gets or sets the kill timer.
+        /// </summary>
+        /// <value>The kill timer.</value>
+        private Timer? KillTimer { get; set; }
+
+        /// <summary>
+        /// Gets or sets device id.
+        /// </summary>
+        public string? DeviceId { get; set; }
+
+        /// <summary>
+        /// Gets or sets cancellation token source.
+        /// </summary>
+        public CancellationTokenSource? CancellationTokenSource { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether has exited.
+        /// </summary>
+        public bool HasExited { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether is user paused.
+        /// </summary>
+        public bool IsUserPaused { get; set; }
+
+        /// <summary>
+        /// Gets or sets id.
+        /// </summary>
+        public string? Id { get; set; }
+
+        /// <summary>
+        /// Gets or sets framerate.
+        /// </summary>
+        public float? Framerate { get; set; }
+
+        /// <summary>
+        /// Gets or sets completion percentage.
+        /// </summary>
+        public double? CompletionPercentage { get; set; }
+
+        /// <summary>
+        /// Gets or sets bytes downloaded.
+        /// </summary>
+        public long? BytesDownloaded { get; set; }
+
+        /// <summary>
+        /// Gets or sets bytes transcoded.
+        /// </summary>
+        public long? BytesTranscoded { get; set; }
+
+        /// <summary>
+        /// Gets or sets bit rate.
+        /// </summary>
+        public int? BitRate { get; set; }
+
+        /// <summary>
+        /// Gets or sets transcoding position ticks.
+        /// </summary>
+        public long? TranscodingPositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets download position ticks.
+        /// </summary>
+        public long? DownloadPositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets transcoding throttler.
+        /// </summary>
+        public TranscodingThrottler? TranscodingThrottler { get; set; }
+
+        /// <summary>
+        /// Gets or sets last ping date.
+        /// </summary>
+        public DateTime LastPingDate { get; set; }
+
+        /// <summary>
+        /// Gets or sets ping timeout.
+        /// </summary>
+        public int PingTimeout { get; set; }
+
+        /// <summary>
+        /// Stop kill timer.
+        /// </summary>
+        public void StopKillTimer()
+        {
+            lock (_timerLock)
+            {
+                KillTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+            }
+        }
+
+        /// <summary>
+        /// Dispose kill timer.
+        /// </summary>
+        public void DisposeKillTimer()
+        {
+            lock (_timerLock)
+            {
+                if (KillTimer != null)
+                {
+                    KillTimer.Dispose();
+                    KillTimer = null;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Start kill timer.
+        /// </summary>
+        /// <param name="callback">Callback action.</param>
+        public void StartKillTimer(Action<object> callback)
+        {
+            StartKillTimer(callback, PingTimeout);
+        }
+
+        /// <summary>
+        /// Start kill timer.
+        /// </summary>
+        /// <param name="callback">Callback action.</param>
+        /// <param name="intervalMs">Callback interval.</param>
+        public void StartKillTimer(Action<object> callback, int intervalMs)
+        {
+            if (HasExited)
+            {
+                return;
+            }
+
+            lock (_timerLock)
+            {
+                if (KillTimer == null)
+                {
+                    Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
+                    KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite);
+                }
+                else
+                {
+                    Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
+                    KillTimer.Change(intervalMs, Timeout.Infinite);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Change kill timer if started.
+        /// </summary>
+        public void ChangeKillTimerIfStarted()
+        {
+            if (HasExited)
+            {
+                return;
+            }
+
+            lock (_timerLock)
+            {
+                if (KillTimer != null)
+                {
+                    var intervalMs = PingTimeout;
+
+                    Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
+                    KillTimer.Change(intervalMs, Timeout.Infinite);
+                }
+            }
+        }
+    }
+}

+ 212 - 0
Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs

@@ -0,0 +1,212 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Models.PlaybackDtos
+{
+    /// <summary>
+    /// Transcoding throttler.
+    /// </summary>
+    public class TranscodingThrottler : IDisposable
+    {
+        private readonly TranscodingJobDto _job;
+        private readonly ILogger<TranscodingThrottler> _logger;
+        private readonly IConfigurationManager _config;
+        private readonly IFileSystem _fileSystem;
+        private Timer? _timer;
+        private bool _isPaused;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TranscodingThrottler"/> class.
+        /// </summary>
+        /// <param name="job">Transcoding job dto.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param>
+        /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem)
+        {
+            _job = job;
+            _logger = logger;
+            _config = config;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        /// Start timer.
+        /// </summary>
+        public void Start()
+        {
+            _timer = new Timer(TimerCallback, null, 5000, 5000);
+        }
+
+        /// <summary>
+        /// Unpause transcoding.
+        /// </summary>
+        /// <returns>A <see cref="Task"/>.</returns>
+        public async Task UnpauseTranscoding()
+        {
+            if (_isPaused)
+            {
+                _logger.LogDebug("Sending resume command to ffmpeg");
+
+                try
+                {
+                    await _job.Process!.StandardInput.WriteLineAsync().ConfigureAwait(false);
+                    _isPaused = false;
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error resuming transcoding");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Stop throttler.
+        /// </summary>
+        /// <returns>A <see cref="Task"/>.</returns>
+        public async Task Stop()
+        {
+            DisposeTimer();
+            await UnpauseTranscoding().ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Dispose throttler.
+        /// </summary>
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <summary>
+        /// Dispose throttler.
+        /// </summary>
+        /// <param name="disposing">Disposing.</param>
+        protected virtual void Dispose(bool disposing)
+        {
+            if (disposing)
+            {
+                DisposeTimer();
+            }
+        }
+
+        private EncodingOptions GetOptions()
+        {
+            return _config.GetConfiguration<EncodingOptions>("encoding");
+        }
+
+        private async void TimerCallback(object state)
+        {
+            if (_job.HasExited)
+            {
+                DisposeTimer();
+                return;
+            }
+
+            var options = GetOptions();
+
+            if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds))
+            {
+                await PauseTranscoding().ConfigureAwait(false);
+            }
+            else
+            {
+                await UnpauseTranscoding().ConfigureAwait(false);
+            }
+        }
+
+        private async Task PauseTranscoding()
+        {
+            if (!_isPaused)
+            {
+                _logger.LogDebug("Sending pause command to ffmpeg");
+
+                try
+                {
+                    await _job.Process!.StandardInput.WriteAsync("c").ConfigureAwait(false);
+                    _isPaused = true;
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error pausing transcoding");
+                }
+            }
+        }
+
+        private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds)
+        {
+            var bytesDownloaded = job.BytesDownloaded ?? 0;
+            var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0;
+            var downloadPositionTicks = job.DownloadPositionTicks ?? 0;
+
+            var path = job.Path;
+            var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks;
+
+            if (downloadPositionTicks > 0 && transcodingPositionTicks > 0)
+            {
+                // HLS - time-based consideration
+
+                var targetGap = gapLengthInTicks;
+                var gap = transcodingPositionTicks - downloadPositionTicks;
+
+                if (gap < targetGap)
+                {
+                    _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap);
+                    return false;
+                }
+
+                _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap);
+                return true;
+            }
+
+            if (bytesDownloaded > 0 && transcodingPositionTicks > 0)
+            {
+                // Progressive Streaming - byte-based consideration
+
+                try
+                {
+                    var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length;
+
+                    // Estimate the bytes the transcoder should be ahead
+                    double gapFactor = gapLengthInTicks;
+                    gapFactor /= transcodingPositionTicks;
+                    var targetGap = bytesTranscoded * gapFactor;
+
+                    var gap = bytesTranscoded - bytesDownloaded;
+
+                    if (gap < targetGap)
+                    {
+                        _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded);
+                        return false;
+                    }
+
+                    _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded);
+                    return true;
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error getting output size");
+                    return false;
+                }
+            }
+
+            _logger.LogDebug("No throttle data for " + path);
+            return false;
+        }
+
+        private void DisposeTimer()
+        {
+            if (_timer != null)
+            {
+                _timer.Dispose();
+                _timer = null;
+            }
+        }
+    }
+}

+ 18 - 0
Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs

@@ -0,0 +1,18 @@
+namespace Jellyfin.Api.Models.UserViewDtos
+{
+    /// <summary>
+    /// Special view option dto.
+    /// </summary>
+    public class SpecialViewOptionDto
+    {
+        /// <summary>
+        /// Gets or sets view option name.
+        /// </summary>
+        public string? Name { get; set; }
+
+        /// <summary>
+        /// Gets or sets view option id.
+        /// </summary>
+        public string? Id { get; set; }
+    }
+}

+ 10 - 1
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -6,6 +6,7 @@ using System.Reflection;
 using Jellyfin.Api;
 using Jellyfin.Api.Auth;
 using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
+using Jellyfin.Api.Auth.DownloadPolicy;
 using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy;
 using Jellyfin.Api.Auth.IgnoreSchedulePolicy;
 using Jellyfin.Api.Auth.LocalAccessPolicy;
@@ -39,6 +40,7 @@ namespace Jellyfin.Server.Extensions
         public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection)
         {
             serviceCollection.AddSingleton<IAuthorizationHandler, DefaultAuthorizationHandler>();
+            serviceCollection.AddSingleton<IAuthorizationHandler, DownloadHandler>();
             serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupOrElevatedHandler>();
             serviceCollection.AddSingleton<IAuthorizationHandler, IgnoreScheduleHandler>();
             serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>();
@@ -52,6 +54,13 @@ namespace Jellyfin.Server.Extensions
                         policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
                         policy.AddRequirements(new DefaultAuthorizationRequirement());
                     });
+                options.AddPolicy(
+                    Policies.Download,
+                    policy =>
+                    {
+                        policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+                        policy.AddRequirements(new DownloadRequirement());
+                    });
                 options.AddPolicy(
                     Policies.FirstTimeSetupOrElevated,
                     policy =>
@@ -186,7 +195,7 @@ namespace Jellyfin.Server.Extensions
 
                 // Order actions by route path, then by http method.
                 c.OrderActionsBy(description =>
-                    $"{description.ActionDescriptor.RouteValues["controller"]}_{description.HttpMethod}");
+                    $"{description.ActionDescriptor.RouteValues["controller"]}_{description.RelativePath}");
 
                 // Use method name as operationId
                 c.CustomOperationIds(description =>

+ 0 - 1116
MediaBrowser.Api/Library/LibraryService.cs

@@ -1,1116 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Net;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using Jellyfin.Data.Entities;
-using MediaBrowser.Api.Movies;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Progress;
-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.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Activity;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Globalization;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-using Microsoft.Net.Http.Headers;
-using Book = MediaBrowser.Controller.Entities.Book;
-using Episode = MediaBrowser.Controller.Entities.TV.Episode;
-using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider;
-using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
-using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
-using Series = MediaBrowser.Controller.Entities.TV.Series;
-
-namespace MediaBrowser.Api.Library
-{
-    [Route("/Items/{Id}/File", "GET", Summary = "Gets the original file of an item")]
-    [Authenticated]
-    public class GetFile
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetCriticReviews
-    /// </summary>
-    [Route("/Items/{Id}/CriticReviews", "GET", Summary = "Gets critic reviews for an item")]
-    [Authenticated]
-    public class GetCriticReviews : IReturn<QueryResult<BaseItemDto>>
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-
-        /// <summary>
-        /// Skips over a given number of items within the results. Use for paging.
-        /// </summary>
-        /// <value>The start index.</value>
-        [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? StartIndex { get; set; }
-
-        /// <summary>
-        /// The maximum number of items to return
-        /// </summary>
-        /// <value>The limit.</value>
-        [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? Limit { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetThemeSongs
-    /// </summary>
-    [Route("/Items/{Id}/ThemeSongs", "GET", Summary = "Gets theme songs for an item")]
-    [Authenticated]
-    public class GetThemeSongs : IReturn<ThemeMediaResult>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public bool InheritFromParent { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetThemeVideos
-    /// </summary>
-    [Route("/Items/{Id}/ThemeVideos", "GET", Summary = "Gets theme videos for an item")]
-    [Authenticated]
-    public class GetThemeVideos : IReturn<ThemeMediaResult>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public bool InheritFromParent { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetThemeVideos
-    /// </summary>
-    [Route("/Items/{Id}/ThemeMedia", "GET", Summary = "Gets theme videos and songs for an item")]
-    [Authenticated]
-    public class GetThemeMedia : IReturn<AllThemeMediaResult>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public bool InheritFromParent { get; set; }
-    }
-
-    [Route("/Library/Refresh", "POST", Summary = "Starts a library scan")]
-    [Authenticated(Roles = "Admin")]
-    public class RefreshLibrary : IReturnVoid
-    {
-    }
-
-    [Route("/Items/{Id}", "DELETE", Summary = "Deletes an item from the library and file system")]
-    [Authenticated]
-    public class DeleteItem : IReturnVoid
-    {
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Items", "DELETE", Summary = "Deletes an item from the library and file system")]
-    [Authenticated]
-    public class DeleteItems : IReturnVoid
-    {
-        [ApiMember(Name = "Ids", Description = "Ids", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string Ids { get; set; }
-    }
-
-    [Route("/Items/Counts", "GET")]
-    [Authenticated]
-    public class GetItemCounts : IReturn<ItemCounts>
-    {
-        [ApiMember(Name = "UserId", Description = "Optional. Get counts from a specific user's library.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        [ApiMember(Name = "IsFavorite", Description = "Optional. Get counts of favorite items", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsFavorite { get; set; }
-    }
-
-    [Route("/Items/{Id}/Ancestors", "GET", Summary = "Gets all parents of an item")]
-    [Authenticated]
-    public class GetAncestors : IReturn<BaseItemDto[]>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetPhyscialPaths
-    /// </summary>
-    [Route("/Library/PhysicalPaths", "GET", Summary = "Gets a list of physical paths from virtual folders")]
-    [Authenticated(Roles = "Admin")]
-    public class GetPhyscialPaths : IReturn<List<string>>
-    {
-    }
-
-    [Route("/Library/MediaFolders", "GET", Summary = "Gets all user media folders.")]
-    [Authenticated]
-    public class GetMediaFolders : IReturn<QueryResult<BaseItemDto>>
-    {
-        [ApiMember(Name = "IsHidden", Description = "Optional. Filter by folders that are marked hidden, or not.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? IsHidden { get; set; }
-    }
-
-    [Route("/Library/Series/Added", "POST", Summary = "Reports that new episodes of a series have been added by an external source")]
-    [Route("/Library/Series/Updated", "POST", Summary = "Reports that new episodes of a series have been added by an external source")]
-    [Authenticated]
-    public class PostUpdatedSeries : IReturnVoid
-    {
-        [ApiMember(Name = "TvdbId", Description = "Tvdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string TvdbId { get; set; }
-    }
-
-    [Route("/Library/Movies/Added", "POST", Summary = "Reports that new movies have been added by an external source")]
-    [Route("/Library/Movies/Updated", "POST", Summary = "Reports that new movies have been added by an external source")]
-    [Authenticated]
-    public class PostUpdatedMovies : IReturnVoid
-    {
-        [ApiMember(Name = "TmdbId", Description = "Tmdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string TmdbId { get; set; }
-        [ApiMember(Name = "ImdbId", Description = "Imdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string ImdbId { get; set; }
-    }
-
-    public class MediaUpdateInfo
-    {
-        public string Path { get; set; }
-
-        // Created, Modified, Deleted
-        public string UpdateType { get; set; }
-    }
-
-    [Route("/Library/Media/Updated", "POST", Summary = "Reports that new movies have been added by an external source")]
-    [Authenticated]
-    public class PostUpdatedMedia : IReturnVoid
-    {
-        [ApiMember(Name = "Updates", Description = "A list of updated media paths", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")]
-        public List<MediaUpdateInfo> Updates { get; set; }
-    }
-
-    [Route("/Items/{Id}/Download", "GET", Summary = "Downloads item media")]
-    [Authenticated(Roles = "download")]
-    public class GetDownload
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Artists/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")]
-    [Route("/Items/{Id}/Similar", "GET", Summary = "Gets similar items")]
-    [Route("/Albums/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")]
-    [Route("/Shows/{Id}/Similar", "GET", Summary = "Finds tv shows similar to a given one.")]
-    [Route("/Movies/{Id}/Similar", "GET", Summary = "Finds movies and trailers similar to a given movie.")]
-    [Route("/Trailers/{Id}/Similar", "GET", Summary = "Finds movies and trailers similar to a given trailer.")]
-    [Authenticated]
-    public class GetSimilarItems : BaseGetSimilarItemsFromItem
-    {
-    }
-
-    [Route("/Libraries/AvailableOptions", "GET")]
-    [Authenticated(AllowBeforeStartupWizard = true)]
-    public class GetLibraryOptionsInfo : IReturn<LibraryOptionsResult>
-    {
-        public string LibraryContentType { get; set; }
-        public bool IsNewLibrary { get; set; }
-    }
-
-    public class LibraryOptionInfo
-    {
-        public string Name { get; set; }
-        public bool DefaultEnabled { get; set; }
-    }
-
-    public class LibraryOptionsResult
-    {
-        public LibraryOptionInfo[] MetadataSavers { get; set; }
-        public LibraryOptionInfo[] MetadataReaders { get; set; }
-        public LibraryOptionInfo[] SubtitleFetchers { get; set; }
-        public LibraryTypeOptions[] TypeOptions { get; set; }
-    }
-
-    public class LibraryTypeOptions
-    {
-        public string Type { get; set; }
-        public LibraryOptionInfo[] MetadataFetchers { get; set; }
-        public LibraryOptionInfo[] ImageFetchers { get; set; }
-        public ImageType[] SupportedImageTypes { get; set; }
-        public ImageOption[] DefaultImageOptions { get; set; }
-    }
-
-    /// <summary>
-    /// Class LibraryService
-    /// </summary>
-    public class LibraryService : BaseApiService
-    {
-        private readonly IProviderManager _providerManager;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IUserManager _userManager;
-        private readonly IDtoService _dtoService;
-        private readonly IAuthorizationContext _authContext;
-        private readonly IActivityManager _activityManager;
-        private readonly ILocalizationManager _localization;
-        private readonly ILibraryMonitor _libraryMonitor;
-
-        private readonly ILogger<MoviesService> _moviesServiceLogger;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="LibraryService" /> class.
-        /// </summary>
-        public LibraryService(
-            ILogger<LibraryService> logger,
-            ILogger<MoviesService> moviesServiceLogger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IProviderManager providerManager,
-            ILibraryManager libraryManager,
-            IUserManager userManager,
-            IDtoService dtoService,
-            IAuthorizationContext authContext,
-            IActivityManager activityManager,
-            ILocalizationManager localization,
-            ILibraryMonitor libraryMonitor)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _providerManager = providerManager;
-            _libraryManager = libraryManager;
-            _userManager = userManager;
-            _dtoService = dtoService;
-            _authContext = authContext;
-            _activityManager = activityManager;
-            _localization = localization;
-            _libraryMonitor = libraryMonitor;
-            _moviesServiceLogger = moviesServiceLogger;
-        }
-
-        // Content Types available for each Library
-        private string[] GetRepresentativeItemTypes(string contentType)
-        {
-            return contentType switch
-            {
-                CollectionType.BoxSets => new[] {"BoxSet"},
-                CollectionType.Playlists => new[] {"Playlist"},
-                CollectionType.Movies => new[] {"Movie"},
-                CollectionType.TvShows => new[] {"Series", "Season", "Episode"},
-                CollectionType.Books => new[] {"Book"},
-                CollectionType.Music => new[] {"MusicArtist", "MusicAlbum", "Audio", "MusicVideo"},
-                CollectionType.HomeVideos => new[] {"Video", "Photo"},
-                CollectionType.Photos => new[] {"Video", "Photo"},
-                CollectionType.MusicVideos => new[] {"MusicVideo"},
-                _ => new[] {"Series", "Season", "Episode", "Movie"}
-            };
-        }
-
-        private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary)
-        {
-            if (isNewLibrary)
-            {
-                return false;
-            }
-
-            var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions
-                .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
-                .ToArray();
-
-            if (metadataOptions.Length == 0)
-            {
-                return true;
-            }
-
-            return metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase));
-        }
-
-        private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
-        {
-            if (isNewLibrary)
-            {
-                if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
-                {
-                    return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
-                         || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
-                         || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase));
-                }
-
-                return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
-                   || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
-                   || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase);
-            }
-
-            var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions
-                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                .ToArray();
-
-            return metadataOptions.Length == 0
-               || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase));
-        }
-
-        private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary)
-        {
-            if (isNewLibrary)
-            {
-                if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase))
-                {
-                    return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase)
-                           && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase)
-                           && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase)
-                           && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase);
-                }
-
-                return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase)
-                       || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase)
-                       || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase)
-                       || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase);
-            }
-
-            var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions
-                .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                .ToArray();
-
-            if (metadataOptions.Length == 0)
-            {
-                return true;
-            }
-
-            return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase));
-        }
-
-        public object Get(GetLibraryOptionsInfo request)
-        {
-            var result = new LibraryOptionsResult();
-
-            var types = GetRepresentativeItemTypes(request.LibraryContentType);
-            var isNewLibrary = request.IsNewLibrary;
-            var typesList = types.ToList();
-
-            var plugins = _providerManager.GetAllMetadataPlugins()
-                .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase))
-                .OrderBy(i => typesList.IndexOf(i.ItemType))
-                .ToList();
-
-            result.MetadataSavers = plugins
-                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver))
-                .Select(i => new LibraryOptionInfo
-                {
-                    Name = i.Name,
-                    DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary)
-                })
-                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                .Select(x => x.First())
-                .ToArray();
-
-            result.MetadataReaders = plugins
-                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider))
-                .Select(i => new LibraryOptionInfo
-                {
-                    Name = i.Name,
-                    DefaultEnabled = true
-                })
-                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                .Select(x => x.First())
-                .ToArray();
-
-            result.SubtitleFetchers = plugins
-                .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher))
-                .Select(i => new LibraryOptionInfo
-                {
-                    Name = i.Name,
-                    DefaultEnabled = true
-                })
-                .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                .Select(x => x.First())
-                .ToArray();
-
-            var typeOptions = new List<LibraryTypeOptions>();
-
-            foreach (var type in types)
-            {
-                TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions);
-
-                typeOptions.Add(new LibraryTypeOptions
-                {
-                    Type = type,
-
-                    MetadataFetchers = plugins
-                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                    .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher))
-                    .Select(i => new LibraryOptionInfo
-                    {
-                        Name = i.Name,
-                        DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary)
-                    })
-                    .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                    .Select(x => x.First())
-                    .ToArray(),
-
-                    ImageFetchers = plugins
-                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                    .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher))
-                    .Select(i => new LibraryOptionInfo
-                    {
-                        Name = i.Name,
-                        DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary)
-                    })
-                    .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
-                    .Select(x => x.First())
-                    .ToArray(),
-
-                    SupportedImageTypes = plugins
-                    .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
-                    .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>())
-                    .Distinct()
-                    .ToArray(),
-
-                    DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>()
-                });
-            }
-
-            result.TypeOptions = typeOptions.ToArray();
-
-            return result;
-        }
-
-        public object Get(GetSimilarItems request)
-        {
-            var item = string.IsNullOrEmpty(request.Id) ?
-                (!request.UserId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() :
-                _libraryManager.RootFolder) : _libraryManager.GetItemById(request.Id);
-
-            var program = item as IHasProgramAttributes;
-
-            if (item is Movie || (program != null && program.IsMovie) || item is Trailer)
-            {
-                return new MoviesService(
-                    _moviesServiceLogger,
-                    ServerConfigurationManager,
-                    ResultFactory,
-                    _userManager,
-                    _libraryManager,
-                    _dtoService,
-                    _authContext)
-                {
-                    Request = Request,
-
-                }.GetSimilarItemsResult(request);
-            }
-
-            if (program != null && program.IsSeries)
-            {
-                return GetSimilarItemsResult(request, new[] { typeof(Series).Name });
-            }
-
-            if (item is Episode || (item is IItemByName && !(item is MusicArtist)))
-            {
-                return new QueryResult<BaseItemDto>();
-            }
-
-            return GetSimilarItemsResult(request, new[] { item.GetType().Name });
-        }
-
-        private QueryResult<BaseItemDto> GetSimilarItemsResult(BaseGetSimilarItemsFromItem request, string[] includeItemTypes)
-        {
-            var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null;
-
-            var item = string.IsNullOrEmpty(request.Id) ?
-                (!request.UserId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() :
-                _libraryManager.RootFolder) : _libraryManager.GetItemById(request.Id);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var query = new InternalItemsQuery(user)
-            {
-                Limit = request.Limit,
-                IncludeItemTypes = includeItemTypes,
-                SimilarTo = item,
-                DtoOptions = dtoOptions,
-                EnableTotalRecordCount = false
-            };
-
-            // ExcludeArtistIds
-            if (!string.IsNullOrEmpty(request.ExcludeArtistIds))
-            {
-                query.ExcludeArtistIds = GetGuids(request.ExcludeArtistIds);
-            }
-
-            List<BaseItem> itemsResult;
-
-            if (item is MusicArtist)
-            {
-                query.IncludeItemTypes = Array.Empty<string>();
-
-                itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList();
-            }
-            else
-            {
-                itemsResult = _libraryManager.GetItemList(query);
-            }
-
-            var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
-
-            var result = new QueryResult<BaseItemDto>
-            {
-                Items = returnList,
-                TotalRecordCount = itemsResult.Count
-            };
-
-            return result;
-        }
-
-        public object Get(GetMediaFolders request)
-        {
-            var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList();
-
-            if (request.IsHidden.HasValue)
-            {
-                var val = request.IsHidden.Value;
-
-                items = items.Where(i => i.IsHidden == val).ToList();
-            }
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var result = new QueryResult<BaseItemDto>
-            {
-                TotalRecordCount = items.Count,
-
-                Items = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions)).ToArray()
-            };
-
-            return result;
-        }
-
-        public void Post(PostUpdatedSeries request)
-        {
-            var series = _libraryManager.GetItemList(new InternalItemsQuery
-            {
-                IncludeItemTypes = new[] { typeof(Series).Name },
-                DtoOptions = new DtoOptions(false)
-                {
-                    EnableImages = false
-                }
-
-            }).Where(i => string.Equals(request.TvdbId, i.GetProviderId(MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray();
-
-            foreach (var item in series)
-            {
-                _libraryMonitor.ReportFileSystemChanged(item.Path);
-            }
-        }
-
-        public void Post(PostUpdatedMedia request)
-        {
-            if (request.Updates != null)
-            {
-                foreach (var item in request.Updates)
-                {
-                    _libraryMonitor.ReportFileSystemChanged(item.Path);
-                }
-            }
-        }
-
-        public void Post(PostUpdatedMovies request)
-        {
-            var movies = _libraryManager.GetItemList(new InternalItemsQuery
-            {
-                IncludeItemTypes = new[] { typeof(Movie).Name },
-                DtoOptions = new DtoOptions(false)
-                {
-                    EnableImages = false
-                }
-
-            });
-
-            if (!string.IsNullOrWhiteSpace(request.ImdbId))
-            {
-                movies = movies.Where(i => string.Equals(request.ImdbId, i.GetProviderId(MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList();
-            }
-            else if (!string.IsNullOrWhiteSpace(request.TmdbId))
-            {
-                movies = movies.Where(i => string.Equals(request.TmdbId, i.GetProviderId(MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList();
-            }
-            else
-            {
-                movies = new List<BaseItem>();
-            }
-
-            foreach (var item in movies)
-            {
-                _libraryMonitor.ReportFileSystemChanged(item.Path);
-            }
-        }
-
-        public Task<object> Get(GetDownload request)
-        {
-            var item = _libraryManager.GetItemById(request.Id);
-            var auth = _authContext.GetAuthorizationInfo(Request);
-
-            var user = auth.User;
-
-            if (user != null)
-            {
-                if (!item.CanDownload(user))
-                {
-                    throw new ArgumentException("Item does not support downloading");
-                }
-            }
-            else
-            {
-                if (!item.CanDownload())
-                {
-                    throw new ArgumentException("Item does not support downloading");
-                }
-            }
-
-            var headers = new Dictionary<string, string>();
-
-            if (user != null)
-            {
-                LogDownload(item, user, auth);
-            }
-
-            var path = item.Path;
-
-            // Quotes are valid in linux. They'll possibly cause issues here
-            var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty);
-            if (!string.IsNullOrWhiteSpace(filename))
-            {
-                // Kestrel doesn't support non-ASCII characters in headers
-                if (Regex.IsMatch(filename, @"[^\p{IsBasicLatin}]"))
-                {
-                    // Manually encoding non-ASCII characters, following https://tools.ietf.org/html/rfc5987#section-3.2.2
-                    headers[HeaderNames.ContentDisposition] = "attachment; filename*=UTF-8''" + WebUtility.UrlEncode(filename);
-                }
-                else
-                {
-                    headers[HeaderNames.ContentDisposition] = "attachment; filename=\"" + filename + "\"";
-                }
-            }
-
-            return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions
-            {
-                Path = path,
-                ResponseHeaders = headers
-            });
-        }
-
-        private void LogDownload(BaseItem item, User user, AuthorizationInfo auth)
-        {
-            try
-            {
-                _activityManager.Create(new ActivityLog(
-                    string.Format(_localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
-                    "UserDownloadingContent",
-                    auth.UserId)
-                {
-                    ShortOverview = string.Format(_localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device),
-                });
-            }
-            catch
-            {
-                // Logged at lower levels
-            }
-        }
-
-        public Task<object> Get(GetFile request)
-        {
-            var item = _libraryManager.GetItemById(request.Id);
-
-            return ResultFactory.GetStaticFileResult(Request, item.Path);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetPhyscialPaths request)
-        {
-            var result = _libraryManager.RootFolder.Children
-                .SelectMany(c => c.PhysicalLocations)
-                .ToList();
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetAncestors request)
-        {
-            var result = GetAncestors(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the ancestors.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>Task{BaseItemDto[]}.</returns>
-        public List<BaseItemDto> GetAncestors(GetAncestors request)
-        {
-            var item = _libraryManager.GetItemById(request.Id);
-
-            var baseItemDtos = new List<BaseItemDto>();
-
-            var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null;
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            BaseItem parent = item.GetParent();
-
-            while (parent != null)
-            {
-                if (user != null)
-                {
-                    parent = TranslateParentItem(parent, user);
-                }
-
-                baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
-
-                parent = parent.GetParent();
-            }
-
-            return baseItemDtos;
-        }
-
-        private BaseItem TranslateParentItem(BaseItem item, User user)
-        {
-            return item.GetParent() is AggregateFolder
-                ? _libraryManager.GetUserRootFolder().GetChildren(user, true)
-                    .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path))
-                : item;
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetCriticReviews request)
-        {
-            return new QueryResult<BaseItemDto>();
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetItemCounts request)
-        {
-            var user = request.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(request.UserId);
-
-            var counts = new ItemCounts
-            {
-                AlbumCount = GetCount(typeof(MusicAlbum), user, request),
-                EpisodeCount = GetCount(typeof(Episode), user, request),
-                MovieCount = GetCount(typeof(Movie), user, request),
-                SeriesCount = GetCount(typeof(Series), user, request),
-                SongCount = GetCount(typeof(Audio), user, request),
-                MusicVideoCount = GetCount(typeof(MusicVideo), user, request),
-                BoxSetCount = GetCount(typeof(BoxSet), user, request),
-                BookCount = GetCount(typeof(Book), user, request)
-            };
-
-            return ToOptimizedResult(counts);
-        }
-
-        private int GetCount(Type type, User user, GetItemCounts request)
-        {
-            var query = new InternalItemsQuery(user)
-            {
-                IncludeItemTypes = new[] { type.Name },
-                Limit = 0,
-                Recursive = true,
-                IsVirtualItem = false,
-                IsFavorite = request.IsFavorite,
-                DtoOptions = new DtoOptions(false)
-                {
-                    EnableImages = false
-                }
-            };
-
-            return _libraryManager.GetItemsResult(query).TotalRecordCount;
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public async Task Post(RefreshLibrary request)
-        {
-            try
-            {
-                await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
-            }
-            catch (Exception ex)
-            {
-                Logger.LogError(ex, "Error refreshing library");
-            }
-        }
-
-        /// <summary>
-        /// Deletes the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Delete(DeleteItems request)
-        {
-            var ids = string.IsNullOrWhiteSpace(request.Ids)
-                ? Array.Empty<string>()
-                : request.Ids.Split(',');
-
-            foreach (var i in ids)
-            {
-                var item = _libraryManager.GetItemById(i);
-                var auth = _authContext.GetAuthorizationInfo(Request);
-                var user = auth.User;
-
-                if (!item.CanDelete(user))
-                {
-                    if (ids.Length > 1)
-                    {
-                        throw new SecurityException("Unauthorized access");
-                    }
-
-                    continue;
-                }
-
-                _libraryManager.DeleteItem(item, new DeleteOptions
-                {
-                    DeleteFileLocation = true
-                }, true);
-            }
-        }
-
-        /// <summary>
-        /// Deletes the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Delete(DeleteItem request)
-        {
-            Delete(new DeleteItems
-            {
-                Ids = request.Id
-            });
-        }
-
-        public object Get(GetThemeMedia request)
-        {
-            var themeSongs = GetThemeSongs(new GetThemeSongs
-            {
-                InheritFromParent = request.InheritFromParent,
-                Id = request.Id,
-                UserId = request.UserId
-
-            });
-
-            var themeVideos = GetThemeVideos(new GetThemeVideos
-            {
-                InheritFromParent = request.InheritFromParent,
-                Id = request.Id,
-                UserId = request.UserId
-
-            });
-
-            return ToOptimizedResult(new AllThemeMediaResult
-            {
-                ThemeSongsResult = themeSongs,
-                ThemeVideosResult = themeVideos,
-
-                SoundtrackSongsResult = new ThemeMediaResult()
-            });
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetThemeSongs request)
-        {
-            var result = GetThemeSongs(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        private ThemeMediaResult GetThemeSongs(GetThemeSongs request)
-        {
-            var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null;
-
-            var item = string.IsNullOrEmpty(request.Id)
-                           ? (!request.UserId.Equals(Guid.Empty)
-                                  ? _libraryManager.GetUserRootFolder()
-                                  : _libraryManager.RootFolder)
-                           : _libraryManager.GetItemById(request.Id);
-
-            if (item == null)
-            {
-                throw new ResourceNotFoundException("Item not found.");
-            }
-
-            IEnumerable<BaseItem> themeItems;
-
-            while (true)
-            {
-                themeItems = item.GetThemeSongs();
-
-                if (themeItems.Any() || !request.InheritFromParent)
-                {
-                    break;
-                }
-
-                var parent = item.GetParent();
-                if (parent == null)
-                {
-                    break;
-                }
-                item = parent;
-            }
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-            var items = themeItems
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
-                .ToArray();
-
-            return new ThemeMediaResult
-            {
-                Items = items,
-                TotalRecordCount = items.Length,
-                OwnerId = item.Id
-            };
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetThemeVideos request)
-        {
-            return ToOptimizedResult(GetThemeVideos(request));
-        }
-
-        public ThemeMediaResult GetThemeVideos(GetThemeVideos request)
-        {
-            var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null;
-
-            var item = string.IsNullOrEmpty(request.Id)
-                           ? (!request.UserId.Equals(Guid.Empty)
-                                  ? _libraryManager.GetUserRootFolder()
-                                  : _libraryManager.RootFolder)
-                           : _libraryManager.GetItemById(request.Id);
-
-            if (item == null)
-            {
-                throw new ResourceNotFoundException("Item not found.");
-            }
-
-            IEnumerable<BaseItem> themeItems;
-
-            while (true)
-            {
-                themeItems = item.GetThemeVideos();
-
-                if (themeItems.Any() || !request.InheritFromParent)
-                {
-                    break;
-                }
-
-                var parent = item.GetParent();
-                if (parent == null)
-                {
-                    break;
-                }
-                item = parent;
-            }
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = themeItems
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
-                .ToArray();
-
-            return new ThemeMediaResult
-            {
-                Items = items,
-                TotalRecordCount = items.Length,
-                OwnerId = item.Id
-            };
-        }
-    }
-}

+ 4 - 0
MediaBrowser.Api/MediaBrowser.Api.csproj

@@ -14,6 +14,10 @@
     <Compile Include="..\SharedVersion.cs" />
   </ItemGroup>
 
+  <ItemGroup>
+    <Folder Include="Library" />
+  </ItemGroup>
+
   <PropertyGroup>
     <TargetFramework>netstandard2.1</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>

+ 0 - 105
MediaBrowser.Api/Movies/CollectionService.cs

@@ -1,105 +0,0 @@
-using System;
-using MediaBrowser.Controller.Collections;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Collections;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.Movies
-{
-    [Route("/Collections", "POST", Summary = "Creates a new collection")]
-    public class CreateCollection : IReturn<CollectionCreationResult>
-    {
-        [ApiMember(Name = "IsLocked", Description = "Whether or not to lock the new collection.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")]
-        public bool IsLocked { get; set; }
-
-        [ApiMember(Name = "Name", Description = "The name of the new collection.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Name { get; set; }
-
-        [ApiMember(Name = "ParentId", Description = "Optional - create the collection within a specific folder", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string ParentId { get; set; }
-
-        [ApiMember(Name = "Ids", Description = "Item Ids to add to the collection", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)]
-        public string Ids { get; set; }
-    }
-
-    [Route("/Collections/{Id}/Items", "POST", Summary = "Adds items to a collection")]
-    public class AddToCollection : IReturnVoid
-    {
-        [ApiMember(Name = "Ids", Description = "Item id, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Ids { get; set; }
-
-        [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Collections/{Id}/Items", "DELETE", Summary = "Removes items from a collection")]
-    public class RemoveFromCollection : IReturnVoid
-    {
-        [ApiMember(Name = "Ids", Description = "Item id, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string Ids { get; set; }
-
-        [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    [Authenticated]
-    public class CollectionService : BaseApiService
-    {
-        private readonly ICollectionManager _collectionManager;
-        private readonly IDtoService _dtoService;
-        private readonly IAuthorizationContext _authContext;
-
-        public CollectionService(
-            ILogger<CollectionService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            ICollectionManager collectionManager,
-            IDtoService dtoService,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _collectionManager = collectionManager;
-            _dtoService = dtoService;
-            _authContext = authContext;
-        }
-
-        public object Post(CreateCollection request)
-        {
-            var userId = _authContext.GetAuthorizationInfo(Request).UserId;
-
-            var parentId = string.IsNullOrWhiteSpace(request.ParentId) ? (Guid?)null : new Guid(request.ParentId);
-
-            var item = _collectionManager.CreateCollection(new CollectionCreationOptions
-            {
-                IsLocked = request.IsLocked,
-                Name = request.Name,
-                ParentId = parentId,
-                ItemIdList = SplitValue(request.Ids, ','),
-                UserIds = new[] { userId }
-
-            });
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
-
-            return new CollectionCreationResult
-            {
-                Id = dto.Id
-            };
-        }
-
-        public void Post(AddToCollection request)
-        {
-            _collectionManager.AddToCollection(new Guid(request.Id), SplitValue(request.Ids, ','));
-        }
-
-        public void Delete(RemoveFromCollection request)
-        {
-            _collectionManager.RemoveFromCollection(new Guid(request.Id), SplitValue(request.Ids, ','));
-        }
-    }
-}

+ 0 - 322
MediaBrowser.Api/Movies/MoviesService.cs

@@ -20,50 +20,6 @@ using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
 
 namespace MediaBrowser.Api.Movies
 {
-    [Route("/Movies/Recommendations", "GET", Summary = "Gets movie recommendations")]
-    public class GetMovieRecommendations : IReturn<RecommendationDto[]>, IHasDtoOptions
-    {
-        [ApiMember(Name = "CategoryLimit", Description = "The max number of categories to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int CategoryLimit { get; set; }
-
-        [ApiMember(Name = "ItemLimit", Description = "The max number of items to return per category", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int ItemLimit { get; set; }
-
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Specify this to localize the search to a specific item or folder. Omit to use the root.
-        /// </summary>
-        /// <value>The parent id.</value>
-        [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string ParentId { get; set; }
-
-        [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableImages { get; set; }
-
-        [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableUserData { get; set; }
-
-        [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? ImageTypeLimit { get; set; }
-
-        [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string EnableImageTypes { get; set; }
-
-        public GetMovieRecommendations()
-        {
-            CategoryLimit = 5;
-            ItemLimit = 8;
-        }
-
-        public string Fields { get; set; }
-    }
-
     /// <summary>
     /// Class MoviesService
     /// </summary>
@@ -74,9 +30,7 @@ namespace MediaBrowser.Api.Movies
         /// The _user manager
         /// </summary>
         private readonly IUserManager _userManager;
-
         private readonly ILibraryManager _libraryManager;
-
         private readonly IDtoService _dtoService;
         private readonly IAuthorizationContext _authContext;
 
@@ -99,17 +53,6 @@ namespace MediaBrowser.Api.Movies
             _authContext = authContext;
         }
 
-        public object Get(GetMovieRecommendations request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var result = GetRecommendationCategories(user, request.ParentId, request.CategoryLimit, request.ItemLimit, dtoOptions);
-
-            return ToOptimizedResult(result);
-        }
-
         public QueryResult<BaseItemDto> GetSimilarItemsResult(BaseGetSimilarItemsFromItem request)
         {
             var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null;
@@ -149,270 +92,5 @@ namespace MediaBrowser.Api.Movies
 
             return result;
         }
-
-        private IEnumerable<RecommendationDto> GetRecommendationCategories(User user, string parentId, int categoryLimit, int itemLimit, DtoOptions dtoOptions)
-        {
-            var categories = new List<RecommendationDto>();
-
-            var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
-
-            var query = new InternalItemsQuery(user)
-            {
-                IncludeItemTypes = new[]
-                {
-                    typeof(Movie).Name,
-                    //typeof(Trailer).Name,
-                    //typeof(LiveTvProgram).Name
-                },
-                // IsMovie = true
-                OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
-                Limit = 7,
-                ParentId = parentIdGuid,
-                Recursive = true,
-                IsPlayed = true,
-                DtoOptions = dtoOptions
-            };
-
-            var recentlyPlayedMovies = _libraryManager.GetItemList(query);
-
-            var itemTypes = new List<string> { typeof(Movie).Name };
-            if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-            {
-                itemTypes.Add(typeof(Trailer).Name);
-                itemTypes.Add(typeof(LiveTvProgram).Name);
-            }
-
-            var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
-            {
-                IncludeItemTypes = itemTypes.ToArray(),
-                IsMovie = true,
-                OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
-                Limit = 10,
-                IsFavoriteOrLiked = true,
-                ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
-                EnableGroupByMetadataKey = true,
-                ParentId = parentIdGuid,
-                Recursive = true,
-                DtoOptions = dtoOptions
-
-            });
-
-            var mostRecentMovies = recentlyPlayedMovies.Take(6).ToList();
-            // Get recently played directors
-            var recentDirectors = GetDirectors(mostRecentMovies)
-                .ToList();
-
-            // Get recently played actors
-            var recentActors = GetActors(mostRecentMovies)
-                .ToList();
-
-            var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
-            var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
-
-            var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
-            var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
-
-            var categoryTypes = new List<IEnumerator<RecommendationDto>>
-            {
-                // Give this extra weight
-                similarToRecentlyPlayed,
-                similarToRecentlyPlayed,
-
-                // Give this extra weight
-                similarToLiked,
-                similarToLiked,
-
-                hasDirectorFromRecentlyPlayed,
-                hasActorFromRecentlyPlayed
-            };
-
-            while (categories.Count < categoryLimit)
-            {
-                var allEmpty = true;
-
-                foreach (var category in categoryTypes)
-                {
-                    if (category.MoveNext())
-                    {
-                        categories.Add(category.Current);
-                        allEmpty = false;
-
-                        if (categories.Count >= categoryLimit)
-                        {
-                            break;
-                        }
-                    }
-                }
-
-                if (allEmpty)
-                {
-                    break;
-                }
-            }
-
-            return categories.OrderBy(i => i.RecommendationType);
-        }
-
-        private IEnumerable<RecommendationDto> GetWithDirector(
-            User user,
-            IEnumerable<string> names,
-            int itemLimit,
-            DtoOptions dtoOptions,
-            RecommendationType type)
-        {
-            var itemTypes = new List<string> { typeof(Movie).Name };
-            if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-            {
-                itemTypes.Add(typeof(Trailer).Name);
-                itemTypes.Add(typeof(LiveTvProgram).Name);
-            }
-
-            foreach (var name in names)
-            {
-                var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
-                {
-                    Person = name,
-                    // Account for duplicates by imdb id, since the database doesn't support this yet
-                    Limit = itemLimit + 2,
-                    PersonTypes = new[] { PersonType.Director },
-                    IncludeItemTypes = itemTypes.ToArray(),
-                    IsMovie = true,
-                    EnableGroupByMetadataKey = true,
-                    DtoOptions = dtoOptions
-
-                }).GroupBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
-                .Select(x => x.First())
-                .Take(itemLimit)
-                .ToList();
-
-                if (items.Count > 0)
-                {
-                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
-
-                    yield return new RecommendationDto
-                    {
-                        BaselineItemName = name,
-                        CategoryId = name.GetMD5(),
-                        RecommendationType = type,
-                        Items = returnItems
-                    };
-                }
-            }
-        }
-
-        private IEnumerable<RecommendationDto> GetWithActor(User user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
-        {
-            var itemTypes = new List<string> { typeof(Movie).Name };
-            if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-            {
-                itemTypes.Add(typeof(Trailer).Name);
-                itemTypes.Add(typeof(LiveTvProgram).Name);
-            }
-
-            foreach (var name in names)
-            {
-                var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
-                {
-                    Person = name,
-                    // Account for duplicates by imdb id, since the database doesn't support this yet
-                    Limit = itemLimit + 2,
-                    IncludeItemTypes = itemTypes.ToArray(),
-                    IsMovie = true,
-                    EnableGroupByMetadataKey = true,
-                    DtoOptions = dtoOptions
-
-                }).GroupBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
-                .Select(x => x.First())
-                .Take(itemLimit)
-                .ToList();
-
-                if (items.Count > 0)
-                {
-                    var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
-
-                    yield return new RecommendationDto
-                    {
-                        BaselineItemName = name,
-                        CategoryId = name.GetMD5(),
-                        RecommendationType = type,
-                        Items = returnItems
-                    };
-                }
-            }
-        }
-
-        private IEnumerable<RecommendationDto> GetSimilarTo(User user, List<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
-        {
-            var itemTypes = new List<string> { typeof(Movie).Name };
-            if (ServerConfigurationManager.Configuration.EnableExternalContentInSuggestions)
-            {
-                itemTypes.Add(typeof(Trailer).Name);
-                itemTypes.Add(typeof(LiveTvProgram).Name);
-            }
-
-            foreach (var item in baselineItems)
-            {
-                var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
-                {
-                    Limit = itemLimit,
-                    IncludeItemTypes = itemTypes.ToArray(),
-                    IsMovie = true,
-                    SimilarTo = item,
-                    EnableGroupByMetadataKey = true,
-                    DtoOptions = dtoOptions
-
-                });
-
-                if (similar.Count > 0)
-                {
-                    var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
-
-                    yield return new RecommendationDto
-                    {
-                        BaselineItemName = item.Name,
-                        CategoryId = item.Id,
-                        RecommendationType = type,
-                        Items = returnItems
-                    };
-                }
-            }
-        }
-
-        private IEnumerable<string> GetActors(List<BaseItem> items)
-        {
-            var people = _libraryManager.GetPeople(new InternalPeopleQuery
-            {
-                ExcludePersonTypes = new[]
-                {
-                    PersonType.Director
-                },
-                MaxListOrder = 3
-            });
-
-            var itemIds = items.Select(i => i.Id).ToList();
-
-            return people
-                .Where(i => itemIds.Contains(i.ItemId))
-                .Select(i => i.Name)
-                .DistinctNames();
-        }
-
-        private IEnumerable<string> GetDirectors(List<BaseItem> items)
-        {
-            var people = _libraryManager.GetPeople(new InternalPeopleQuery
-            {
-                PersonTypes = new[]
-                {
-                    PersonType.Director
-                }
-            });
-
-            var itemIds = items.Select(i => i.Id).ToList();
-
-            return people
-                .Where(i => itemIds.Contains(i.ItemId))
-                .Select(i => i.Name)
-                .DistinctNames();
-        }
     }
 }

+ 0 - 132
MediaBrowser.Api/Music/AlbumsService.cs

@@ -1,132 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Persistence;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.Music
-{
-    [Route("/Albums/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")]
-    public class GetSimilarAlbums : BaseGetSimilarItemsFromItem
-    {
-    }
-
-    [Route("/Artists/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")]
-    public class GetSimilarArtists : BaseGetSimilarItemsFromItem
-    {
-    }
-
-    [Authenticated]
-    public class AlbumsService : BaseApiService
-    {
-        /// <summary>
-        /// The _user manager
-        /// </summary>
-        private readonly IUserManager _userManager;
-
-        /// <summary>
-        /// The _user data repository
-        /// </summary>
-        private readonly IUserDataManager _userDataRepository;
-        /// <summary>
-        /// The _library manager
-        /// </summary>
-        private readonly ILibraryManager _libraryManager;
-        private readonly IItemRepository _itemRepo;
-        private readonly IDtoService _dtoService;
-        private readonly IAuthorizationContext _authContext;
-
-        public AlbumsService(
-            ILogger<AlbumsService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            IUserDataManager userDataRepository,
-            ILibraryManager libraryManager,
-            IItemRepository itemRepo,
-            IDtoService dtoService,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _userManager = userManager;
-            _userDataRepository = userDataRepository;
-            _libraryManager = libraryManager;
-            _itemRepo = itemRepo;
-            _dtoService = dtoService;
-            _authContext = authContext;
-        }
-
-        public object Get(GetSimilarArtists request)
-        {
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var result = SimilarItemsHelper.GetSimilarItemsResult(
-                dtoOptions, 
-                _userManager,
-                _itemRepo,
-                _libraryManager,
-                _userDataRepository,
-                _dtoService,
-                request, new[] { typeof(MusicArtist) },
-                SimilarItemsHelper.GetSimiliarityScore);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetSimilarAlbums request)
-        {
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var result = SimilarItemsHelper.GetSimilarItemsResult(
-                dtoOptions, 
-                _userManager,
-                _itemRepo,
-                _libraryManager,
-                _userDataRepository,
-                _dtoService,
-                request, new[] { typeof(MusicAlbum) },
-                GetAlbumSimilarityScore);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the album similarity score.
-        /// </summary>
-        /// <param name="item1">The item1.</param>
-        /// <param name="item1People">The item1 people.</param>
-        /// <param name="allPeople">All people.</param>
-        /// <param name="item2">The item2.</param>
-        /// <returns>System.Int32.</returns>
-        private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2)
-        {
-            var points = SimilarItemsHelper.GetSimiliarityScore(item1, item1People, allPeople, item2);
-
-            var album1 = (MusicAlbum)item1;
-            var album2 = (MusicAlbum)item2;
-
-            var artists1 = album1
-                .GetAllArtists()
-                .DistinctNames()
-                .ToList();
-
-            var artists2 = new HashSet<string>(
-                album2.GetAllArtists().DistinctNames(),
-                StringComparer.OrdinalIgnoreCase);
-
-            return points + artists1.Where(artists2.Contains).Sum(i => 5);
-        }
-    }
-}

+ 0 - 197
MediaBrowser.Api/Music/InstantMixService.cs

@@ -1,197 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using Jellyfin.Data.Entities;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Playlists;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.Music
-{
-    [Route("/Songs/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given song")]
-    public class GetInstantMixFromSong : BaseGetSimilarItemsFromItem
-    {
-    }
-
-    [Route("/Albums/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given album")]
-    public class GetInstantMixFromAlbum : BaseGetSimilarItemsFromItem
-    {
-    }
-
-    [Route("/Playlists/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given playlist")]
-    public class GetInstantMixFromPlaylist : BaseGetSimilarItemsFromItem
-    {
-    }
-
-    [Route("/MusicGenres/{Name}/InstantMix", "GET", Summary = "Creates an instant playlist based on a music genre")]
-    public class GetInstantMixFromMusicGenre : BaseGetSimilarItems
-    {
-        [ApiMember(Name = "Name", Description = "The genre name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Name { get; set; }
-    }
-
-    [Route("/Artists/InstantMix", "GET", Summary = "Creates an instant playlist based on a given artist")]
-    public class GetInstantMixFromArtistId : BaseGetSimilarItems
-    {
-        [ApiMember(Name = "Id", Description = "The artist Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    [Route("/MusicGenres/InstantMix", "GET", Summary = "Creates an instant playlist based on a music genre")]
-    public class GetInstantMixFromMusicGenreId : BaseGetSimilarItems
-    {
-        [ApiMember(Name = "Id", Description = "The genre Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Items/{Id}/InstantMix", "GET", Summary = "Creates an instant playlist based on a given item")]
-    public class GetInstantMixFromItem : BaseGetSimilarItemsFromItem
-    {
-    }
-
-    [Authenticated]
-    public class InstantMixService : BaseApiService
-    {
-        private readonly IUserManager _userManager;
-
-        private readonly IDtoService _dtoService;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IMusicManager _musicManager;
-        private readonly IAuthorizationContext _authContext;
-
-        public InstantMixService(
-            ILogger<InstantMixService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            IDtoService dtoService,
-            IMusicManager musicManager,
-            ILibraryManager libraryManager,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _userManager = userManager;
-            _dtoService = dtoService;
-            _musicManager = musicManager;
-            _libraryManager = libraryManager;
-            _authContext = authContext;
-        }
-
-        public object Get(GetInstantMixFromItem request)
-        {
-            var item = _libraryManager.GetItemById(request.Id);
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-
-            return GetResult(items, user, request, dtoOptions);
-        }
-
-        public object Get(GetInstantMixFromArtistId request)
-        {
-            var item = _libraryManager.GetItemById(request.Id);
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-
-            return GetResult(items, user, request, dtoOptions);
-        }
-
-        public object Get(GetInstantMixFromMusicGenreId request)
-        {
-            var item = _libraryManager.GetItemById(request.Id);
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-
-            return GetResult(items, user, request, dtoOptions);
-        }
-
-        public object Get(GetInstantMixFromSong request)
-        {
-            var item = _libraryManager.GetItemById(request.Id);
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
-
-            return GetResult(items, user, request, dtoOptions);
-        }
-
-        public object Get(GetInstantMixFromAlbum request)
-        {
-            var album = _libraryManager.GetItemById(request.Id);
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
-
-            return GetResult(items, user, request, dtoOptions);
-        }
-
-        public object Get(GetInstantMixFromPlaylist request)
-        {
-            var playlist = (Playlist)_libraryManager.GetItemById(request.Id);
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
-
-            return GetResult(items, user, request, dtoOptions);
-        }
-
-        public object Get(GetInstantMixFromMusicGenre request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var items = _musicManager.GetInstantMixFromGenres(new[] { request.Name }, user, dtoOptions);
-
-            return GetResult(items, user, request, dtoOptions);
-        }
-
-        private object GetResult(List<BaseItem> items, User user, BaseGetSimilarItems request, DtoOptions dtoOptions)
-        {
-            var list = items;
-
-            var result = new QueryResult<BaseItemDto>
-            {
-                TotalRecordCount = list.Count
-            };
-
-            if (request.Limit.HasValue)
-            {
-                list = list.Take(request.Limit.Value).ToList();
-            }
-
-            var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
-
-            result.Items = returnList;
-
-            return result;
-        }
-
-    }
-}

+ 0 - 5
MediaBrowser.Api/Playback/MediaInfoService.cs

@@ -28,7 +28,6 @@ using Microsoft.Extensions.Logging;
 
 namespace MediaBrowser.Api.Playback
 {
-    [Route("/Items/{Id}/PlaybackInfo", "GET", Summary = "Gets live playback media info for an item")]
     public class GetPlaybackInfo : IReturn<PlaybackInfoResponse>
     {
         [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
@@ -38,24 +37,20 @@ namespace MediaBrowser.Api.Playback
         public Guid UserId { get; set; }
     }
 
-    [Route("/Items/{Id}/PlaybackInfo", "POST", Summary = "Gets live playback media info for an item")]
     public class GetPostedPlaybackInfo : PlaybackInfoRequest, IReturn<PlaybackInfoResponse>
     {
     }
 
-    [Route("/LiveStreams/Open", "POST", Summary = "Opens a media source")]
     public class OpenMediaSource : LiveStreamRequest, IReturn<LiveStreamResponse>
     {
     }
 
-    [Route("/LiveStreams/Close", "POST", Summary = "Closes a media source")]
     public class CloseMediaSource : IReturnVoid
     {
         [ApiMember(Name = "LiveStreamId", Description = "LiveStreamId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
         public string LiveStreamId { get; set; }
     }
 
-    [Route("/Playback/BitrateTest", "GET")]
     public class GetBitrateTestBytes
     {
         [ApiMember(Name = "Size", Description = "Size", IsRequired = true, DataType = "int", ParameterType = "query", Verb = "GET")]

+ 0 - 234
MediaBrowser.Api/ScheduledTasks/ScheduledTaskService.cs

@@ -1,234 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Services;
-using MediaBrowser.Model.Tasks;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.ScheduledTasks
-{
-    /// <summary>
-    /// Class GetScheduledTask
-    /// </summary>
-    [Route("/ScheduledTasks/{Id}", "GET", Summary = "Gets a scheduled task, by Id")]
-    public class GetScheduledTask : IReturn<TaskInfo>
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetScheduledTasks
-    /// </summary>
-    [Route("/ScheduledTasks", "GET", Summary = "Gets scheduled tasks")]
-    public class GetScheduledTasks : IReturn<TaskInfo[]>
-    {
-        [ApiMember(Name = "IsHidden", Description = "Optional filter tasks that are hidden, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsHidden { get; set; }
-
-        [ApiMember(Name = "IsEnabled", Description = "Optional filter tasks that are enabled, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsEnabled { get; set; }
-    }
-
-    /// <summary>
-    /// Class StartScheduledTask
-    /// </summary>
-    [Route("/ScheduledTasks/Running/{Id}", "POST", Summary = "Starts a scheduled task")]
-    public class StartScheduledTask : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class StopScheduledTask
-    /// </summary>
-    [Route("/ScheduledTasks/Running/{Id}", "DELETE", Summary = "Stops a scheduled task")]
-    public class StopScheduledTask : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class UpdateScheduledTaskTriggers
-    /// </summary>
-    [Route("/ScheduledTasks/{Id}/Triggers", "POST", Summary = "Updates the triggers for a scheduled task")]
-    public class UpdateScheduledTaskTriggers : List<TaskTriggerInfo>, IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the task id.
-        /// </summary>
-        /// <value>The task id.</value>
-        [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class ScheduledTasksService
-    /// </summary>
-    [Authenticated(Roles = "Admin")]
-    public class ScheduledTaskService : BaseApiService
-    {
-        /// <summary>
-        /// The task manager.
-        /// </summary>
-        private readonly ITaskManager _taskManager;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ScheduledTaskService" /> class.
-        /// </summary>
-        /// <param name="taskManager">The task manager.</param>
-        /// <exception cref="ArgumentNullException">taskManager</exception>
-        public ScheduledTaskService(
-            ILogger<ScheduledTaskService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            ITaskManager taskManager)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _taskManager = taskManager;
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>IEnumerable{TaskInfo}.</returns>
-        public object Get(GetScheduledTasks request)
-        {
-            IEnumerable<IScheduledTaskWorker> result = _taskManager.ScheduledTasks
-                .OrderBy(i => i.Name);
-
-            if (request.IsHidden.HasValue)
-            {
-                var val = request.IsHidden.Value;
-
-                result = result.Where(i =>
-                {
-                    var isHidden = false;
-
-                    if (i.ScheduledTask is IConfigurableScheduledTask configurableTask)
-                    {
-                        isHidden = configurableTask.IsHidden;
-                    }
-
-                    return isHidden == val;
-                });
-            }
-
-            if (request.IsEnabled.HasValue)
-            {
-                var val = request.IsEnabled.Value;
-
-                result = result.Where(i =>
-                {
-                    var isEnabled = true;
-
-                    if (i.ScheduledTask is IConfigurableScheduledTask configurableTask)
-                    {
-                        isEnabled = configurableTask.IsEnabled;
-                    }
-
-                    return isEnabled == val;
-                });
-            }
-
-            var infos = result
-                .Select(ScheduledTaskHelpers.GetTaskInfo)
-                .ToArray();
-
-            return ToOptimizedResult(infos);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>IEnumerable{TaskInfo}.</returns>
-        /// <exception cref="ResourceNotFoundException">Task not found</exception>
-        public object Get(GetScheduledTask request)
-        {
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, request.Id));
-
-            if (task == null)
-            {
-                throw new ResourceNotFoundException("Task not found");
-            }
-
-            var result = ScheduledTaskHelpers.GetTaskInfo(task);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <exception cref="ResourceNotFoundException">Task not found</exception>
-        public void Post(StartScheduledTask request)
-        {
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, request.Id));
-
-            if (task == null)
-            {
-                throw new ResourceNotFoundException("Task not found");
-            }
-
-            _taskManager.Execute(task, new TaskOptions());
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <exception cref="ResourceNotFoundException">Task not found</exception>
-        public void Delete(StopScheduledTask request)
-        {
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, request.Id));
-
-            if (task == null)
-            {
-                throw new ResourceNotFoundException("Task not found");
-            }
-
-            _taskManager.Cancel(task);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <exception cref="ResourceNotFoundException">Task not found</exception>
-        public void Post(UpdateScheduledTaskTriggers request)
-        {
-            // We need to parse this manually because we told service stack not to with IRequiresRequestStream
-            // https://code.google.com/p/servicestack/source/browse/trunk/Common/ServiceStack.Text/ServiceStack.Text/Controller/PathInfo.cs
-            var id = GetPathValue(1).ToString();
-
-            var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.Ordinal));
-
-            if (task == null)
-            {
-                throw new ResourceNotFoundException("Task not found");
-            }
-
-            task.Triggers = request.ToArray();
-        }
-    }
-}

+ 0 - 226
MediaBrowser.Api/System/SystemService.cs

@@ -1,226 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Net;
-using MediaBrowser.Model.Services;
-using MediaBrowser.Model.System;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.System
-{
-    /// <summary>
-    /// Class GetSystemInfo
-    /// </summary>
-    [Route("/System/Info", "GET", Summary = "Gets information about the server")]
-    [Authenticated(EscapeParentalControl = true, AllowBeforeStartupWizard = true)]
-    public class GetSystemInfo : IReturn<SystemInfo>
-    {
-
-    }
-
-    [Route("/System/Info/Public", "GET", Summary = "Gets public information about the server")]
-    public class GetPublicSystemInfo : IReturn<PublicSystemInfo>
-    {
-
-    }
-
-    [Route("/System/Ping", "POST")]
-    [Route("/System/Ping", "GET")]
-    public class PingSystem : IReturnVoid
-    {
-
-    }
-
-    /// <summary>
-    /// Class RestartApplication
-    /// </summary>
-    [Route("/System/Restart", "POST", Summary = "Restarts the application, if needed")]
-    [Authenticated(Roles = "Admin", AllowLocal = true)]
-    public class RestartApplication
-    {
-    }
-
-    /// <summary>
-    /// This is currently not authenticated because the uninstaller needs to be able to shutdown the server.
-    /// </summary>
-    [Route("/System/Shutdown", "POST", Summary = "Shuts down the application")]
-    [Authenticated(Roles = "Admin", AllowLocal = true)]
-    public class ShutdownApplication
-    {
-    }
-
-    [Route("/System/Logs", "GET", Summary = "Gets a list of available server log files")]
-    [Authenticated(Roles = "Admin")]
-    public class GetServerLogs : IReturn<LogFile[]>
-    {
-    }
-
-    [Route("/System/Endpoint", "GET", Summary = "Gets information about the request endpoint")]
-    [Authenticated]
-    public class GetEndpointInfo : IReturn<EndPointInfo>
-    {
-        public string Endpoint { get; set; }
-    }
-
-    [Route("/System/Logs/Log", "GET", Summary = "Gets a log file")]
-    [Authenticated(Roles = "Admin")]
-    public class GetLogFile
-    {
-        [ApiMember(Name = "Name", Description = "The log file name.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string Name { get; set; }
-    }
-
-    [Route("/System/WakeOnLanInfo", "GET", Summary = "Gets wake on lan information")]
-    [Authenticated]
-    public class GetWakeOnLanInfo : IReturn<WakeOnLanInfo[]>
-    {
-
-    }
-
-    /// <summary>
-    /// Class SystemInfoService
-    /// </summary>
-    public class SystemService : BaseApiService
-    {
-        /// <summary>
-        /// The _app host
-        /// </summary>
-        private readonly IServerApplicationHost _appHost;
-        private readonly IApplicationPaths _appPaths;
-        private readonly IFileSystem _fileSystem;
-
-        private readonly INetworkManager _network;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SystemService" /> class.
-        /// </summary>
-        /// <param name="appHost">The app host.</param>
-        /// <param name="fileSystem">The file system.</param>
-        /// <exception cref="ArgumentNullException">jsonSerializer</exception>
-        public SystemService(
-            ILogger<SystemService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IServerApplicationHost appHost,
-            IFileSystem fileSystem,
-            INetworkManager network)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _appPaths = serverConfigurationManager.ApplicationPaths;
-            _appHost = appHost;
-            _fileSystem = fileSystem;
-            _network = network;
-        }
-
-        public object Post(PingSystem request)
-        {
-            return _appHost.Name;
-        }
-
-        public object Get(GetWakeOnLanInfo request)
-        {
-            var result = _appHost.GetWakeOnLanInfo();
-
-            return ToOptimizedResult(result);
-        }
-
-        public object Get(GetServerLogs request)
-        {
-            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 ToOptimizedResult(result);
-        }
-
-        public Task<object> Get(GetLogFile request)
-        {
-            var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
-                .First(i => string.Equals(i.Name, request.Name, StringComparison.OrdinalIgnoreCase));
-
-            // For older files, assume fully static
-            var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite;
-
-            return ResultFactory.GetStaticFileResult(Request, file.FullName, fileShare);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public async Task<object> Get(GetSystemInfo request)
-        {
-            var result = await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false);
-
-            return ToOptimizedResult(result);
-        }
-
-        public async Task<object> Get(GetPublicSystemInfo request)
-        {
-            var result = await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(RestartApplication request)
-        {
-            _appHost.Restart();
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(ShutdownApplication request)
-        {
-            Task.Run(async () =>
-            {
-                await Task.Delay(100).ConfigureAwait(false);
-                await _appHost.Shutdown().ConfigureAwait(false);
-            });
-        }
-
-        public object Get(GetEndpointInfo request)
-        {
-            return ToOptimizedResult(new EndPointInfo
-            {
-                IsLocal = Request.IsLocal,
-                IsInNetwork = _network.IsInLocalNetwork(request.Endpoint ?? Request.RemoteIp)
-            });
-        }
-    }
-}

+ 0 - 498
MediaBrowser.Api/TvShowsService.cs

@@ -1,498 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.TV;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api
-{
-    /// <summary>
-    /// Class GetNextUpEpisodes
-    /// </summary>
-    [Route("/Shows/NextUp", "GET", Summary = "Gets a list of next up episodes")]
-    public class GetNextUpEpisodes : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Skips over a given number of items within the results. Use for paging.
-        /// </summary>
-        /// <value>The start index.</value>
-        [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? StartIndex { get; set; }
-
-        /// <summary>
-        /// The maximum number of items to return
-        /// </summary>
-        /// <value>The limit.</value>
-        [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? Limit { get; set; }
-
-        /// <summary>
-        /// Fields to return within the items, in addition to basic information
-        /// </summary>
-        /// <value>The fields.</value>
-        [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string Fields { get; set; }
-
-        [ApiMember(Name = "SeriesId", Description = "Optional. Filter by series id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string SeriesId { get; set; }
-
-        /// <summary>
-        /// Specify this to localize the search to a specific item or folder. Omit to use the root.
-        /// </summary>
-        /// <value>The parent id.</value>
-        [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string ParentId { get; set; }
-
-        [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableImages { get; set; }
-
-        [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? ImageTypeLimit { get; set; }
-
-        [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string EnableImageTypes { get; set; }
-
-        [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableUserData { get; set; }
-        public bool EnableTotalRecordCount { get; set; }
-
-        public GetNextUpEpisodes()
-        {
-            EnableTotalRecordCount = true;
-        }
-    }
-
-    [Route("/Shows/Upcoming", "GET", Summary = "Gets a list of upcoming episodes")]
-    public class GetUpcomingEpisodes : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Skips over a given number of items within the results. Use for paging.
-        /// </summary>
-        /// <value>The start index.</value>
-        [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? StartIndex { get; set; }
-
-        /// <summary>
-        /// The maximum number of items to return
-        /// </summary>
-        /// <value>The limit.</value>
-        [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? Limit { get; set; }
-
-        /// <summary>
-        /// Fields to return within the items, in addition to basic information
-        /// </summary>
-        /// <value>The fields.</value>
-        [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string Fields { get; set; }
-
-        /// <summary>
-        /// Specify this to localize the search to a specific item or folder. Omit to use the root.
-        /// </summary>
-        /// <value>The parent id.</value>
-        [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string ParentId { get; set; }
-
-        [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableImages { get; set; }
-
-        [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? ImageTypeLimit { get; set; }
-
-        [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string EnableImageTypes { get; set; }
-
-        [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableUserData { get; set; }
-    }
-
-    [Route("/Shows/{Id}/Episodes", "GET", Summary = "Gets episodes for a tv season")]
-    public class GetEpisodes : IReturn<QueryResult<BaseItemDto>>, IHasItemFields, IHasDtoOptions
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Fields to return within the items, in addition to basic information
-        /// </summary>
-        /// <value>The fields.</value>
-        [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string Fields { get; set; }
-
-        [ApiMember(Name = "Id", Description = "The series id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "Season", Description = "Optional filter by season number.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public int? Season { get; set; }
-
-        [ApiMember(Name = "SeasonId", Description = "Optional. Filter by season id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string SeasonId { get; set; }
-
-        [ApiMember(Name = "IsMissing", Description = "Optional filter by items that are missing episodes or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsMissing { get; set; }
-
-        [ApiMember(Name = "AdjacentTo", Description = "Optional. Return items that are siblings of a supplied item.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string AdjacentTo { get; set; }
-
-        [ApiMember(Name = "StartItemId", Description = "Optional. Skip through the list until a given item is found.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string StartItemId { get; set; }
-
-        /// <summary>
-        /// Skips over a given number of items within the results. Use for paging.
-        /// </summary>
-        /// <value>The start index.</value>
-        [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? StartIndex { get; set; }
-
-        /// <summary>
-        /// The maximum number of items to return
-        /// </summary>
-        /// <value>The limit.</value>
-        [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? Limit { get; set; }
-
-        [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableImages { get; set; }
-
-        [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? ImageTypeLimit { get; set; }
-
-        [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string EnableImageTypes { get; set; }
-
-        [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableUserData { get; set; }
-
-        [ApiMember(Name = "SortBy", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string SortBy { get; set; }
-
-        [ApiMember(Name = "SortOrder", Description = "Sort Order - Ascending,Descending", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public SortOrder? SortOrder { get; set; }
-    }
-
-    [Route("/Shows/{Id}/Seasons", "GET", Summary = "Gets seasons for a tv series")]
-    public class GetSeasons : IReturn<QueryResult<BaseItemDto>>, IHasItemFields, IHasDtoOptions
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Fields to return within the items, in addition to basic information
-        /// </summary>
-        /// <value>The fields.</value>
-        [ApiMember(Name = "Fields", Description = "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", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string Fields { get; set; }
-
-        [ApiMember(Name = "Id", Description = "The series id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "IsSpecialSeason", Description = "Optional. Filter by special season.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsSpecialSeason { get; set; }
-
-        [ApiMember(Name = "IsMissing", Description = "Optional filter by items that are missing episodes or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsMissing { get; set; }
-
-        [ApiMember(Name = "AdjacentTo", Description = "Optional. Return items that are siblings of a supplied item.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string AdjacentTo { get; set; }
-
-        [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableImages { get; set; }
-
-        [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? ImageTypeLimit { get; set; }
-
-        [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string EnableImageTypes { get; set; }
-
-        [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableUserData { get; set; }
-    }
-
-    /// <summary>
-    /// Class TvShowsService
-    /// </summary>
-    [Authenticated]
-    public class TvShowsService : BaseApiService
-    {
-        /// <summary>
-        /// The _user manager
-        /// </summary>
-        private readonly IUserManager _userManager;
-
-        /// <summary>
-        /// The _library manager
-        /// </summary>
-        private readonly ILibraryManager _libraryManager;
-
-        private readonly IDtoService _dtoService;
-        private readonly ITVSeriesManager _tvSeriesManager;
-        private readonly IAuthorizationContext _authContext;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="TvShowsService" /> class.
-        /// </summary>
-        /// <param name="userManager">The user manager.</param>
-        /// <param name="userDataManager">The user data repository.</param>
-        /// <param name="libraryManager">The library manager.</param>
-        public TvShowsService(
-            ILogger<TvShowsService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IDtoService dtoService,
-            ITVSeriesManager tvSeriesManager,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _dtoService = dtoService;
-            _tvSeriesManager = tvSeriesManager;
-            _authContext = authContext;
-        }
-
-        public object Get(GetUpcomingEpisodes request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1);
-
-            var parentIdGuid = string.IsNullOrWhiteSpace(request.ParentId) ? Guid.Empty : new Guid(request.ParentId);
-
-            var options = GetDtoOptions(_authContext, request);
-
-            var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
-            {
-                IncludeItemTypes = new[] { typeof(Episode).Name },
-                OrderBy = new[] { ItemSortBy.PremiereDate, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(),
-                MinPremiereDate = minPremiereDate,
-                StartIndex = request.StartIndex,
-                Limit = request.Limit,
-                ParentId = parentIdGuid,
-                Recursive = true,
-                DtoOptions = options
-
-            });
-
-            var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user);
-
-            var result = new QueryResult<BaseItemDto>
-            {
-                TotalRecordCount = itemsResult.Count,
-                Items = returnItems
-            };
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetNextUpEpisodes request)
-        {
-            var options = GetDtoOptions(_authContext, request);
-
-            var result = _tvSeriesManager.GetNextUp(new NextUpQuery
-            {
-                Limit = request.Limit,
-                ParentId = request.ParentId,
-                SeriesId = request.SeriesId,
-                StartIndex = request.StartIndex,
-                UserId = request.UserId,
-                EnableTotalRecordCount = request.EnableTotalRecordCount
-            }, options);
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
-
-            return ToOptimizedResult(new QueryResult<BaseItemDto>
-            {
-                TotalRecordCount = result.TotalRecordCount,
-                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;
-        }
-
-        public object Get(GetSeasons request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var series = GetSeries(request.Id);
-
-            if (series == null)
-            {
-                throw new ResourceNotFoundException("Series not found");
-            }
-
-            var seasons = series.GetItemList(new InternalItemsQuery(user)
-            {
-                IsMissing = request.IsMissing,
-                IsSpecialSeason = request.IsSpecialSeason,
-                AdjacentTo = request.AdjacentTo
-
-            });
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
-
-            return new QueryResult<BaseItemDto>
-            {
-                TotalRecordCount = returnItems.Count,
-                Items = returnItems
-            };
-        }
-
-        private Series GetSeries(string seriesId)
-        {
-            if (!string.IsNullOrWhiteSpace(seriesId))
-            {
-                return _libraryManager.GetItemById(seriesId) as Series;
-            }
-
-            return null;
-        }
-
-        public object Get(GetEpisodes request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            List<BaseItem> episodes;
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            if (!string.IsNullOrWhiteSpace(request.SeasonId))
-            {
-                if (!(_libraryManager.GetItemById(new Guid(request.SeasonId)) is Season season))
-                {
-                    throw new ResourceNotFoundException("No season exists with Id " + request.SeasonId);
-                }
-
-                episodes = season.GetEpisodes(user, dtoOptions);
-            }
-            else if (request.Season.HasValue)
-            {
-                var series = GetSeries(request.Id);
-
-                if (series == null)
-                {
-                    throw new ResourceNotFoundException("Series not found");
-                }
-
-                var season = series.GetSeasons(user, dtoOptions).FirstOrDefault(i => i.IndexNumber == request.Season.Value);
-
-                episodes = season == null ? new List<BaseItem>() : ((Season)season).GetEpisodes(user, dtoOptions);
-            }
-            else
-            {
-                var series = GetSeries(request.Id);
-
-                if (series == null)
-                {
-                    throw new ResourceNotFoundException("Series not found");
-                }
-
-                episodes = series.GetEpisodes(user, dtoOptions).ToList();
-            }
-
-            // Filter after the fact in case the ui doesn't want them
-            if (request.IsMissing.HasValue)
-            {
-                var val = request.IsMissing.Value;
-                episodes = episodes.Where(i => ((Episode)i).IsMissingEpisode == val).ToList();
-            }
-
-            if (!string.IsNullOrWhiteSpace(request.StartItemId))
-            {
-                episodes = episodes.SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), request.StartItemId, StringComparison.OrdinalIgnoreCase)).ToList();
-            }
-
-            // This must be the last filter
-            if (!string.IsNullOrEmpty(request.AdjacentTo))
-            {
-                episodes = UserViewBuilder.FilterForAdjacency(episodes, request.AdjacentTo).ToList();
-            }
-
-            if (string.Equals(request.SortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase))
-            {
-                episodes.Shuffle();
-            }
-
-            var returnItems = episodes;
-
-            if (request.StartIndex.HasValue || request.Limit.HasValue)
-            {
-                returnItems = ApplyPaging(episodes, request.StartIndex, request.Limit).ToList();
-            }
-
-            var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
-
-            return new QueryResult<BaseItemDto>
-            {
-                TotalRecordCount = episodes.Count,
-                Items = dtos
-            };
-        }
-    }
-}

+ 0 - 143
MediaBrowser.Api/UserLibrary/ArtistsService.cs

@@ -1,143 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.UserLibrary
-{
-    /// <summary>
-    /// Class GetArtists
-    /// </summary>
-    [Route("/Artists", "GET", Summary = "Gets all artists from a given item, folder, or the entire library")]
-    public class GetArtists : GetItemsByName
-    {
-    }
-
-    [Route("/Artists/AlbumArtists", "GET", Summary = "Gets all album artists from a given item, folder, or the entire library")]
-    public class GetAlbumArtists : GetItemsByName
-    {
-    }
-
-    [Route("/Artists/{Name}", "GET", Summary = "Gets an artist, by name")]
-    public class GetArtist : IReturn<BaseItemDto>
-    {
-        /// <summary>
-        /// Gets or sets the name.
-        /// </summary>
-        /// <value>The name.</value>
-        [ApiMember(Name = "Name", Description = "The artist name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-    }
-
-    /// <summary>
-    /// Class ArtistsService
-    /// </summary>
-    [Authenticated]
-    public class ArtistsService : BaseItemsByNameService<MusicArtist>
-    {
-        public ArtistsService(
-            ILogger<ArtistsService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IUserDataManager userDataRepository,
-            IDtoService dtoService,
-            IAuthorizationContext authorizationContext)
-            : base(
-                logger,
-                serverConfigurationManager,
-                httpResultFactory,
-                userManager,
-                libraryManager,
-                userDataRepository,
-                dtoService,
-                authorizationContext)
-        {
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetArtist request)
-        {
-            return GetItem(request);
-        }
-
-        /// <summary>
-        /// Gets the item.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>Task{BaseItemDto}.</returns>
-        private BaseItemDto GetItem(GetArtist request)
-        {
-            var dtoOptions = GetDtoOptions(AuthorizationContext, request);
-
-            var item = GetArtist(request.Name, LibraryManager, dtoOptions);
-
-            if (!request.UserId.Equals(Guid.Empty))
-            {
-                var user = UserManager.GetUserById(request.UserId);
-
-                return DtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
-
-            return DtoService.GetBaseItemDto(item, dtoOptions);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetArtists request)
-        {
-            return GetResultSlim(request);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetAlbumArtists request)
-        {
-            var result = GetResultSlim(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        protected override QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query)
-        {
-            return request is GetAlbumArtists ? LibraryManager.GetAlbumArtists(query) : LibraryManager.GetArtists(query);
-        }
-
-        /// <summary>
-        /// Gets all items.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <param name="items">The items.</param>
-        /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns>
-        protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items)
-        {
-            throw new NotImplementedException();
-        }
-    }
-}

+ 0 - 456
MediaBrowser.Api/UserLibrary/PlaystateService.cs

@@ -1,456 +0,0 @@
-using System;
-using System.Globalization;
-using System.Threading.Tasks;
-using Jellyfin.Data.Entities;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Services;
-using MediaBrowser.Model.Session;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.UserLibrary
-{
-    /// <summary>
-    /// Class MarkPlayedItem
-    /// </summary>
-    [Route("/Users/{UserId}/PlayedItems/{Id}", "POST", Summary = "Marks an item as played")]
-    public class MarkPlayedItem : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; }
-
-        [ApiMember(Name = "DatePlayed", Description = "The date the item was played (if any). Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string DatePlayed { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class MarkUnplayedItem
-    /// </summary>
-    [Route("/Users/{UserId}/PlayedItems/{Id}", "DELETE", Summary = "Marks an item as unplayed")]
-    public class MarkUnplayedItem : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Sessions/Playing", "POST", Summary = "Reports playback has started within a session")]
-    public class ReportPlaybackStart : PlaybackStartInfo, IReturnVoid
-    {
-    }
-
-    [Route("/Sessions/Playing/Progress", "POST", Summary = "Reports playback progress within a session")]
-    public class ReportPlaybackProgress : PlaybackProgressInfo, IReturnVoid
-    {
-    }
-
-    [Route("/Sessions/Playing/Ping", "POST", Summary = "Pings a playback session")]
-    public class PingPlaybackSession : IReturnVoid
-    {
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-    }
-
-    [Route("/Sessions/Playing/Stopped", "POST", Summary = "Reports playback has stopped within a session")]
-    public class ReportPlaybackStopped : PlaybackStopInfo, IReturnVoid
-    {
-    }
-
-    /// <summary>
-    /// Class OnPlaybackStart
-    /// </summary>
-    [Route("/Users/{UserId}/PlayingItems/{Id}", "POST", Summary = "Reports that a user has begun playing an item")]
-    public class OnPlaybackStart : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string MediaSourceId { get; set; }
-
-        [ApiMember(Name = "CanSeek", Description = "Indicates if the client can seek", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")]
-        public bool CanSeek { get; set; }
-
-        [ApiMember(Name = "AudioStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? AudioStreamIndex { get; set; }
-
-        [ApiMember(Name = "SubtitleStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? SubtitleStreamIndex { get; set; }
-
-        [ApiMember(Name = "PlayMethod", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public PlayMethod PlayMethod { get; set; }
-
-        [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string LiveStreamId { get; set; }
-
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-    }
-
-    /// <summary>
-    /// Class OnPlaybackProgress
-    /// </summary>
-    [Route("/Users/{UserId}/PlayingItems/{Id}/Progress", "POST", Summary = "Reports a user's playback progress")]
-    public class OnPlaybackProgress : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string MediaSourceId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the position ticks.
-        /// </summary>
-        /// <value>The position ticks.</value>
-        [ApiMember(Name = "PositionTicks", Description = "Optional. The current position, in ticks. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public long? PositionTicks { get; set; }
-
-        [ApiMember(Name = "IsPaused", Description = "Indicates if the player is paused.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")]
-        public bool IsPaused { get; set; }
-
-        [ApiMember(Name = "IsMuted", Description = "Indicates if the player is muted.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")]
-        public bool IsMuted { get; set; }
-
-        [ApiMember(Name = "AudioStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? AudioStreamIndex { get; set; }
-
-        [ApiMember(Name = "SubtitleStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? SubtitleStreamIndex { get; set; }
-
-        [ApiMember(Name = "VolumeLevel", Description = "Scale of 0-100", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? VolumeLevel { get; set; }
-
-        [ApiMember(Name = "PlayMethod", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public PlayMethod PlayMethod { get; set; }
-
-        [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string LiveStreamId { get; set; }
-
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-
-        [ApiMember(Name = "RepeatMode", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public RepeatMode RepeatMode { get; set; }
-    }
-
-    /// <summary>
-    /// Class OnPlaybackStopped
-    /// </summary>
-    [Route("/Users/{UserId}/PlayingItems/{Id}", "DELETE", Summary = "Reports that a user has stopped playing an item")]
-    public class OnPlaybackStopped : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string MediaSourceId { get; set; }
-
-        [ApiMember(Name = "NextMediaType", Description = "The next media type that will play", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string NextMediaType { get; set; }
-
-        /// <summary>
-        /// Gets or sets the position ticks.
-        /// </summary>
-        /// <value>The position ticks.</value>
-        [ApiMember(Name = "PositionTicks", Description = "Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "DELETE")]
-        public long? PositionTicks { get; set; }
-
-        [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string LiveStreamId { get; set; }
-
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-    }
-
-    [Authenticated]
-    public class PlaystateService : BaseApiService
-    {
-        private readonly IUserManager _userManager;
-        private readonly IUserDataManager _userDataRepository;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ISessionManager _sessionManager;
-        private readonly ISessionContext _sessionContext;
-        private readonly IAuthorizationContext _authContext;
-
-        public PlaystateService(
-            ILogger<PlaystateService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            IUserDataManager userDataRepository,
-            ILibraryManager libraryManager,
-            ISessionManager sessionManager,
-            ISessionContext sessionContext,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _userManager = userManager;
-            _userDataRepository = userDataRepository;
-            _libraryManager = libraryManager;
-            _sessionManager = sessionManager;
-            _sessionContext = sessionContext;
-            _authContext = authContext;
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Post(MarkPlayedItem request)
-        {
-            var result = MarkPlayed(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        private UserItemDataDto MarkPlayed(MarkPlayedItem request)
-        {
-            var user = _userManager.GetUserById(Guid.Parse(request.UserId));
-
-            DateTime? datePlayed = null;
-
-            if (!string.IsNullOrEmpty(request.DatePlayed))
-            {
-                datePlayed = DateTime.ParseExact(request.DatePlayed, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
-            }
-
-            var session = GetSession(_sessionContext);
-
-            var dto = UpdatePlayedStatus(user, request.Id, true, datePlayed);
-
-            foreach (var additionalUserInfo in session.AdditionalUsers)
-            {
-                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
-
-                UpdatePlayedStatus(additionalUser, request.Id, true, datePlayed);
-            }
-
-            return dto;
-        }
-
-        private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId)
-        {
-            if (method == PlayMethod.Transcode)
-            {
-                var job = string.IsNullOrWhiteSpace(playSessionId) ? null : ApiEntryPoint.Instance.GetTranscodingJob(playSessionId);
-                if (job == null)
-                {
-                    return PlayMethod.DirectPlay;
-                }
-            }
-
-            return method;
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(OnPlaybackStart request)
-        {
-            Post(new ReportPlaybackStart
-            {
-                CanSeek = request.CanSeek,
-                ItemId = new Guid(request.Id),
-                MediaSourceId = request.MediaSourceId,
-                AudioStreamIndex = request.AudioStreamIndex,
-                SubtitleStreamIndex = request.SubtitleStreamIndex,
-                PlayMethod = request.PlayMethod,
-                PlaySessionId = request.PlaySessionId,
-                LiveStreamId = request.LiveStreamId
-            });
-        }
-
-        public void Post(ReportPlaybackStart request)
-        {
-            request.PlayMethod = ValidatePlayMethod(request.PlayMethod, request.PlaySessionId);
-
-            request.SessionId = GetSession(_sessionContext).Id;
-
-            var task = _sessionManager.OnPlaybackStart(request);
-
-            Task.WaitAll(task);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(OnPlaybackProgress request)
-        {
-            Post(new ReportPlaybackProgress
-            {
-                ItemId = new Guid(request.Id),
-                PositionTicks = request.PositionTicks,
-                IsMuted = request.IsMuted,
-                IsPaused = request.IsPaused,
-                MediaSourceId = request.MediaSourceId,
-                AudioStreamIndex = request.AudioStreamIndex,
-                SubtitleStreamIndex = request.SubtitleStreamIndex,
-                VolumeLevel = request.VolumeLevel,
-                PlayMethod = request.PlayMethod,
-                PlaySessionId = request.PlaySessionId,
-                LiveStreamId = request.LiveStreamId,
-                RepeatMode = request.RepeatMode
-            });
-        }
-
-        public void Post(ReportPlaybackProgress request)
-        {
-            request.PlayMethod = ValidatePlayMethod(request.PlayMethod, request.PlaySessionId);
-
-            request.SessionId = GetSession(_sessionContext).Id;
-
-            var task = _sessionManager.OnPlaybackProgress(request);
-
-            Task.WaitAll(task);
-        }
-
-        public void Post(PingPlaybackSession request)
-        {
-            ApiEntryPoint.Instance.PingTranscodingJob(request.PlaySessionId, null);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public Task Delete(OnPlaybackStopped request)
-        {
-            return Post(new ReportPlaybackStopped
-            {
-                ItemId = new Guid(request.Id),
-                PositionTicks = request.PositionTicks,
-                MediaSourceId = request.MediaSourceId,
-                PlaySessionId = request.PlaySessionId,
-                LiveStreamId = request.LiveStreamId,
-                NextMediaType = request.NextMediaType
-            });
-        }
-
-        public async Task Post(ReportPlaybackStopped request)
-        {
-            Logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", request.PlaySessionId ?? string.Empty);
-
-            if (!string.IsNullOrWhiteSpace(request.PlaySessionId))
-            {
-                await ApiEntryPoint.Instance.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, request.PlaySessionId, s => true);
-            }
-
-            request.SessionId = GetSession(_sessionContext).Id;
-
-            await _sessionManager.OnPlaybackStopped(request);
-        }
-
-        /// <summary>
-        /// Deletes the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Delete(MarkUnplayedItem request)
-        {
-            var task = MarkUnplayed(request);
-
-            return ToOptimizedResult(task);
-        }
-
-        private UserItemDataDto MarkUnplayed(MarkUnplayedItem request)
-        {
-            var user = _userManager.GetUserById(Guid.Parse(request.UserId));
-
-            var session = GetSession(_sessionContext);
-
-            var dto = UpdatePlayedStatus(user, request.Id, false, null);
-
-            foreach (var additionalUserInfo in session.AdditionalUsers)
-            {
-                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
-
-                UpdatePlayedStatus(additionalUser, request.Id, false, null);
-            }
-
-            return dto;
-        }
-
-        /// <summary>
-        /// Updates the played status.
-        /// </summary>
-        /// <param name="user">The user.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
-        /// <param name="datePlayed">The date played.</param>
-        /// <returns>Task.</returns>
-        private UserItemDataDto UpdatePlayedStatus(User user, string itemId, bool wasPlayed, DateTime? datePlayed)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-
-            if (wasPlayed)
-            {
-                item.MarkPlayed(user, datePlayed, true);
-            }
-            else
-            {
-                item.MarkUnplayed(user);
-            }
-
-            return _userDataRepository.GetUserDataDto(item, user);
-        }
-    }
-}

+ 0 - 132
MediaBrowser.Api/UserLibrary/StudiosService.cs

@@ -1,132 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.UserLibrary
-{
-    /// <summary>
-    /// Class GetStudios
-    /// </summary>
-    [Route("/Studios", "GET", Summary = "Gets all studios from a given item, folder, or the entire library")]
-    public class GetStudios : GetItemsByName
-    {
-    }
-
-    /// <summary>
-    /// Class GetStudio
-    /// </summary>
-    [Route("/Studios/{Name}", "GET", Summary = "Gets a studio, by name")]
-    public class GetStudio : IReturn<BaseItemDto>
-    {
-        /// <summary>
-        /// Gets or sets the name.
-        /// </summary>
-        /// <value>The name.</value>
-        [ApiMember(Name = "Name", Description = "The studio name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-    }
-
-    /// <summary>
-    /// Class StudiosService
-    /// </summary>
-    [Authenticated]
-    public class StudiosService : BaseItemsByNameService<Studio>
-    {
-        public StudiosService(
-            ILogger<StudiosService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IUserDataManager userDataRepository,
-            IDtoService dtoService,
-            IAuthorizationContext authorizationContext)
-            : base(
-                logger,
-                serverConfigurationManager,
-                httpResultFactory,
-                userManager,
-                libraryManager,
-                userDataRepository,
-                dtoService,
-                authorizationContext)
-        {
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetStudio request)
-        {
-            var result = GetItem(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the item.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>Task{BaseItemDto}.</returns>
-        private BaseItemDto GetItem(GetStudio request)
-        {
-            var dtoOptions = GetDtoOptions(AuthorizationContext, request);
-
-            var item = GetStudio(request.Name, LibraryManager, dtoOptions);
-
-            if (!request.UserId.Equals(Guid.Empty))
-            {
-                var user = UserManager.GetUserById(request.UserId);
-
-                return DtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
-
-            return DtoService.GetBaseItemDto(item, dtoOptions);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetStudios request)
-        {
-            var result = GetResultSlim(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        protected override QueryResult<(BaseItem, ItemCounts)> GetItems(GetItemsByName request, InternalItemsQuery query)
-        {
-            return LibraryManager.GetStudios(query);
-        }
-
-        /// <summary>
-        /// Gets all items.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <param name="items">The items.</param>
-        /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns>
-        protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items)
-        {
-            throw new NotImplementedException();
-        }
-    }
-}

+ 0 - 575
MediaBrowser.Api/UserLibrary/UserLibraryService.cs

@@ -1,575 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.UserLibrary
-{
-    /// <summary>
-    /// Class GetItem
-    /// </summary>
-    [Route("/Users/{UserId}/Items/{Id}", "GET", Summary = "Gets an item from a user's library")]
-    public class GetItem : IReturn<BaseItemDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetItem
-    /// </summary>
-    [Route("/Users/{UserId}/Items/Root", "GET", Summary = "Gets the root folder from a user's library")]
-    public class GetRootFolder : IReturn<BaseItemDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetIntros
-    /// </summary>
-    [Route("/Users/{UserId}/Items/{Id}/Intros", "GET", Summary = "Gets intros to play before the main media item plays")]
-    public class GetIntros : IReturn<QueryResult<BaseItemDto>>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the item id.
-        /// </summary>
-        /// <value>The item id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class MarkFavoriteItem
-    /// </summary>
-    [Route("/Users/{UserId}/FavoriteItems/{Id}", "POST", Summary = "Marks an item as a favorite")]
-    public class MarkFavoriteItem : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public Guid Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class UnmarkFavoriteItem
-    /// </summary>
-    [Route("/Users/{UserId}/FavoriteItems/{Id}", "DELETE", Summary = "Unmarks an item as a favorite")]
-    public class UnmarkFavoriteItem : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public Guid Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class ClearUserItemRating
-    /// </summary>
-    [Route("/Users/{UserId}/Items/{Id}/Rating", "DELETE", Summary = "Deletes a user's saved personal rating for an item")]
-    public class DeleteUserItemRating : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public Guid Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class UpdateUserItemRating
-    /// </summary>
-    [Route("/Users/{UserId}/Items/{Id}/Rating", "POST", Summary = "Updates a user's rating for an item")]
-    public class UpdateUserItemRating : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public Guid Id { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether this <see cref="UpdateUserItemRating" /> is likes.
-        /// </summary>
-        /// <value><c>true</c> if likes; otherwise, <c>false</c>.</value>
-        [ApiMember(Name = "Likes", Description = "Whether the user likes the item or not. true/false", IsRequired = true, DataType = "boolean", ParameterType = "query", Verb = "POST")]
-        public bool Likes { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetLocalTrailers
-    /// </summary>
-    [Route("/Users/{UserId}/Items/{Id}/LocalTrailers", "GET", Summary = "Gets local trailers for an item")]
-    public class GetLocalTrailers : IReturn<BaseItemDto[]>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetSpecialFeatures
-    /// </summary>
-    [Route("/Users/{UserId}/Items/{Id}/SpecialFeatures", "GET", Summary = "Gets special features for an item")]
-    public class GetSpecialFeatures : IReturn<BaseItemDto[]>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Movie Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Users/{UserId}/Items/Latest", "GET", Summary = "Gets latest media")]
-    public class GetLatestMedia : IReturn<BaseItemDto[]>, IHasDtoOptions
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        [ApiMember(Name = "Limit", Description = "Limit", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int Limit { get; set; }
-
-        [ApiMember(Name = "ParentId", Description = "Specify this to localize the search to a specific item or folder. Omit to use the root", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid ParentId { get; set; }
-
-        [ApiMember(Name = "Fields", Description = "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string Fields { get; set; }
-
-        [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
-        public string IncludeItemTypes { get; set; }
-
-        [ApiMember(Name = "IsFolder", Description = "Filter by items that are folders, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsFolder { get; set; }
-
-        [ApiMember(Name = "IsPlayed", Description = "Filter by items that are played, or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool? IsPlayed { get; set; }
-
-        [ApiMember(Name = "GroupItems", Description = "Whether or not to group items into a parent container.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
-        public bool GroupItems { get; set; }
-
-        [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableImages { get; set; }
-
-        [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
-        public int? ImageTypeLimit { get; set; }
-
-        [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string EnableImageTypes { get; set; }
-
-        [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? EnableUserData { get; set; }
-
-        public GetLatestMedia()
-        {
-            Limit = 20;
-            GroupItems = true;
-        }
-    }
-
-    /// <summary>
-    /// Class UserLibraryService
-    /// </summary>
-    [Authenticated]
-    public class UserLibraryService : BaseApiService
-    {
-        private readonly IUserManager _userManager;
-        private readonly IUserDataManager _userDataRepository;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IUserViewManager _userViewManager;
-        private readonly IFileSystem _fileSystem;
-        private readonly IAuthorizationContext _authContext;
-
-        public UserLibraryService(
-            ILogger<UserLibraryService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IUserDataManager userDataRepository,
-            IDtoService dtoService,
-            IUserViewManager userViewManager,
-            IFileSystem fileSystem,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _userManager = userManager;
-            _libraryManager = libraryManager;
-            _userDataRepository = userDataRepository;
-            _dtoService = dtoService;
-            _userViewManager = userViewManager;
-            _fileSystem = fileSystem;
-            _authContext = authContext;
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetSpecialFeatures request)
-        {
-            var result = GetAsync(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        public object Get(GetLatestMedia request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            if (!request.IsPlayed.HasValue)
-            {
-                if (user.HidePlayedInLatest)
-                {
-                    request.IsPlayed = false;
-                }
-            }
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var list = _userViewManager.GetLatestItems(new LatestItemsQuery
-            {
-                GroupItems = request.GroupItems,
-                IncludeItemTypes = ApiEntryPoint.Split(request.IncludeItemTypes, ',', true),
-                IsPlayed = request.IsPlayed,
-                Limit = request.Limit,
-                ParentId = request.ParentId,
-                UserId = request.UserId,
-            }, dtoOptions);
-
-            var dtos = list.Select(i =>
-            {
-                var item = i.Item2[0];
-                var childCount = 0;
-
-                if (i.Item1 != null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum))
-                {
-                    item = i.Item1;
-                    childCount = i.Item2.Count;
-                }
-
-                var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
-
-                dto.ChildCount = childCount;
-
-                return dto;
-            });
-
-            return ToOptimizedResult(dtos.ToArray());
-        }
-
-        private BaseItemDto[] GetAsync(GetSpecialFeatures request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var item = string.IsNullOrEmpty(request.Id) ?
-                _libraryManager.GetUserRootFolder() :
-                _libraryManager.GetItemById(request.Id);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var dtos = item
-                .GetExtras(BaseItem.DisplayExtraTypes)
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item));
-
-            return dtos.ToArray();
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetLocalTrailers request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var dtosExtras = item.GetExtras(new[] { ExtraType.Trailer })
-                .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
-                .ToArray();
-
-            if (item is IHasTrailers hasTrailers)
-            {
-                var trailers = hasTrailers.GetTrailers();
-                var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item);
-                var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count];
-                dtosExtras.CopyTo(allTrailers, 0);
-                dtosTrailers.CopyTo(allTrailers, dtosExtras.Length);
-                return ToOptimizedResult(allTrailers);
-            }
-
-            return ToOptimizedResult(dtosExtras);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public async Task<object> Get(GetItem request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id);
-
-            await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var result = _dtoService.GetBaseItemDto(item, dtoOptions, user);
-
-            return ToOptimizedResult(result);
-        }
-
-        private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
-        {
-            if (item is Person)
-            {
-                var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
-                var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
-
-                if (!hasMetdata)
-                {
-                    var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-                    {
-                        MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ImageRefreshMode = MetadataRefreshMode.FullRefresh,
-                        ForceSave = performFullRefresh
-                    };
-
-                    await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
-                }
-            }
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetRootFolder request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var item = _libraryManager.GetUserRootFolder();
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var result = _dtoService.GetBaseItemDto(item, dtoOptions, user);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public async Task<object> Get(GetIntros request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var item = string.IsNullOrEmpty(request.Id) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(request.Id);
-
-            var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-
-            var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
-
-            var result = new QueryResult<BaseItemDto>
-            {
-                Items = dtos,
-                TotalRecordCount = dtos.Length
-            };
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Post(MarkFavoriteItem request)
-        {
-            var dto = MarkFavorite(request.UserId, request.Id, true);
-
-            return ToOptimizedResult(dto);
-        }
-
-        /// <summary>
-        /// Deletes the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Delete(UnmarkFavoriteItem request)
-        {
-            var dto = MarkFavorite(request.UserId, request.Id, false);
-
-            return ToOptimizedResult(dto);
-        }
-
-        /// <summary>
-        /// Marks the favorite.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param>
-        private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite)
-        {
-            var user = _userManager.GetUserById(userId);
-
-            var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
-
-            // Get the user data for this item
-            var data = _userDataRepository.GetUserData(user, item);
-
-            // Set favorite status
-            data.IsFavorite = isFavorite;
-
-            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
-
-            return _userDataRepository.GetUserDataDto(item, user);
-        }
-
-        /// <summary>
-        /// Deletes the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Delete(DeleteUserItemRating request)
-        {
-            var dto = UpdateUserItemRating(request.UserId, request.Id, null);
-
-            return ToOptimizedResult(dto);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Post(UpdateUserItemRating request)
-        {
-            var dto = UpdateUserItemRating(request.UserId, request.Id, request.Likes);
-
-            return ToOptimizedResult(dto);
-        }
-
-        /// <summary>
-        /// Updates the user item rating.
-        /// </summary>
-        /// <param name="userId">The user id.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="likes">if set to <c>true</c> [likes].</param>
-        private UserItemDataDto UpdateUserItemRating(Guid userId, Guid itemId, bool? likes)
-        {
-            var user = _userManager.GetUserById(userId);
-
-            var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
-
-            // Get the user data for this item
-            var data = _userDataRepository.GetUserData(user, item);
-
-            data.Likes = likes;
-
-            _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
-
-            return _userDataRepository.GetUserDataDto(item, user);
-        }
-    }
-}

+ 0 - 146
MediaBrowser.Api/UserLibrary/UserViewsService.cs

@@ -1,146 +0,0 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Library;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.UserLibrary
-{
-    [Route("/Users/{UserId}/Views", "GET")]
-    public class GetUserViews : IReturn<QueryResult<BaseItemDto>>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-
-        [ApiMember(Name = "IncludeExternalContent", Description = "Whether or not to include external views such as channels or live tv", IsRequired = true, DataType = "boolean", ParameterType = "query", Verb = "GET")]
-        public bool? IncludeExternalContent { get; set; }
-        public bool IncludeHidden { get; set; }
-
-        public string PresetViews { get; set; }
-    }
-
-    [Route("/Users/{UserId}/GroupingOptions", "GET")]
-    public class GetGroupingOptions : IReturn<SpecialViewOption[]>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public Guid UserId { get; set; }
-    }
-
-    public class UserViewsService : BaseApiService
-    {
-        private readonly IUserManager _userManager;
-        private readonly IUserViewManager _userViewManager;
-        private readonly IDtoService _dtoService;
-        private readonly IAuthorizationContext _authContext;
-        private readonly ILibraryManager _libraryManager;
-
-        public UserViewsService(
-            ILogger<UserViewsService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            IUserViewManager userViewManager,
-            IDtoService dtoService,
-            IAuthorizationContext authContext,
-            ILibraryManager libraryManager)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _userManager = userManager;
-            _userViewManager = userViewManager;
-            _dtoService = dtoService;
-            _authContext = authContext;
-            _libraryManager = libraryManager;
-        }
-
-        public object Get(GetUserViews request)
-        {
-            var query = new UserViewQuery
-            {
-                UserId = request.UserId
-            };
-
-            if (request.IncludeExternalContent.HasValue)
-            {
-                query.IncludeExternalContent = request.IncludeExternalContent.Value;
-            }
-            query.IncludeHidden = request.IncludeHidden;
-
-            if (!string.IsNullOrWhiteSpace(request.PresetViews))
-            {
-                query.PresetViews = request.PresetViews.Split(',');
-            }
-
-            var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;
-            if (app.IndexOf("emby rt", StringComparison.OrdinalIgnoreCase) != -1)
-            {
-                query.PresetViews = new[] { CollectionType.Movies, CollectionType.TvShows };
-            }
-
-            var folders = _userViewManager.GetUserViews(query);
-
-            var dtoOptions = GetDtoOptions(_authContext, request);
-            var fields = dtoOptions.Fields.ToList();
-
-            fields.Add(ItemFields.PrimaryImageAspectRatio);
-            fields.Add(ItemFields.DisplayPreferencesId);
-            fields.Remove(ItemFields.BasicSyncInfo);
-            dtoOptions.Fields = fields.ToArray();
-
-            var user = _userManager.GetUserById(request.UserId);
-
-            var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
-                .ToArray();
-
-            var result = new QueryResult<BaseItemDto>
-            {
-                Items = dtos,
-                TotalRecordCount = dtos.Length
-            };
-
-            return ToOptimizedResult(result);
-        }
-
-        public object Get(GetGroupingOptions request)
-        {
-            var user = _userManager.GetUserById(request.UserId);
-
-            var list = _libraryManager.GetUserRootFolder()
-                .GetChildren(user, true)
-                .OfType<Folder>()
-                .Where(UserView.IsEligibleForGrouping)
-                .Select(i => new SpecialViewOption
-                {
-                    Name = i.Name,
-                    Id = i.Id.ToString("N", CultureInfo.InvariantCulture)
-
-                })
-            .OrderBy(i => i.Name)
-            .ToArray();
-
-            return ToOptimizedResult(list);
-        }
-    }
-
-    class SpecialViewOption
-    {
-        public string Name { get; set; }
-        public string Id { get; set; }
-    }
-}

+ 0 - 131
MediaBrowser.Api/UserLibrary/YearsService.cs

@@ -1,131 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.UserLibrary
-{
-    /// <summary>
-    /// Class GetYears
-    /// </summary>
-    [Route("/Years", "GET", Summary = "Gets all years from a given item, folder, or the entire library")]
-    public class GetYears : GetItemsByName
-    {
-    }
-
-    /// <summary>
-    /// Class GetYear
-    /// </summary>
-    [Route("/Years/{Year}", "GET", Summary = "Gets a year")]
-    public class GetYear : IReturn<BaseItemDto>
-    {
-        /// <summary>
-        /// Gets or sets the year.
-        /// </summary>
-        /// <value>The year.</value>
-        [ApiMember(Name = "Year", Description = "The year", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")]
-        public int Year { get; set; }
-
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public Guid UserId { get; set; }
-    }
-
-    /// <summary>
-    /// Class YearsService
-    /// </summary>
-    [Authenticated]
-    public class YearsService : BaseItemsByNameService<Year>
-    {
-        public YearsService(
-            ILogger<YearsService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            ILibraryManager libraryManager,
-            IUserDataManager userDataRepository,
-            IDtoService dtoService,
-            IAuthorizationContext authorizationContext)
-            : base(
-                logger,
-                serverConfigurationManager,
-                httpResultFactory,
-                userManager,
-                libraryManager,
-                userDataRepository,
-                dtoService,
-                authorizationContext)
-        {
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetYear request)
-        {
-            var result = GetItem(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the item.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>Task{BaseItemDto}.</returns>
-        private BaseItemDto GetItem(GetYear request)
-        {
-            var item = LibraryManager.GetYear(request.Year);
-
-            var dtoOptions = GetDtoOptions(AuthorizationContext, request);
-
-            if (!request.UserId.Equals(Guid.Empty))
-            {
-                var user = UserManager.GetUserById(request.UserId);
-
-                return DtoService.GetBaseItemDto(item, dtoOptions, user);
-            }
-
-            return DtoService.GetBaseItemDto(item, dtoOptions);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetYears request)
-        {
-            var result = GetResult(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets all items.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <param name="items">The items.</param>
-        /// <returns>IEnumerable{Tuple{System.StringFunc{System.Int32}}}.</returns>
-        protected override IEnumerable<BaseItem> GetAllItems(GetItemsByName request, IList<BaseItem> items)
-        {
-            return items
-                .Select(i => i.ProductionYear ?? 0)
-                .Where(i => i > 0)
-                .Distinct()
-                .Select(year => LibraryManager.GetYear(year));
-        }
-    }
-}

+ 56 - 0
MediaBrowser.Common/Json/Converters/JsonInt64Converter.cs

@@ -0,0 +1,56 @@
+using System;
+using System.Buffers;
+using System.Buffers.Text;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Long to String JSON converter.
+    /// Javascript does not support 64-bit integers.
+    /// </summary>
+    public class JsonInt64Converter : JsonConverter<long>
+    {
+        /// <summary>
+        /// Read JSON string as int64.
+        /// </summary>
+        /// <param name="reader"><see cref="Utf8JsonReader"/>.</param>
+        /// <param name="type">Type.</param>
+        /// <param name="options">Options.</param>
+        /// <returns>Parsed value.</returns>
+        public override long Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
+        {
+            if (reader.TokenType == JsonTokenType.String)
+            {
+                // try to parse number directly from bytes
+                var span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan;
+                if (Utf8Parser.TryParse(span, out long number, out var bytesConsumed) && span.Length == bytesConsumed)
+                {
+                    return number;
+                }
+
+                // try to parse from a string if the above failed, this covers cases with other escaped/UTF characters
+                if (long.TryParse(reader.GetString(), out number))
+                {
+                    return number;
+                }
+            }
+
+            // fallback to default handling
+            return reader.GetInt64();
+        }
+
+        /// <summary>
+        /// Write long to JSON string.
+        /// </summary>
+        /// <param name="writer"><see cref="Utf8JsonWriter"/>.</param>
+        /// <param name="value">Value to write.</param>
+        /// <param name="options">Options.</param>
+        public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
+        {
+            writer.WriteStringValue(value.ToString(NumberFormatInfo.InvariantInfo));
+        }
+    }
+}

+ 1 - 0
MediaBrowser.Common/Json/JsonDefaults.cs

@@ -31,6 +31,7 @@ namespace MediaBrowser.Common.Json
             options.Converters.Add(new JsonInt32Converter());
             options.Converters.Add(new JsonStringEnumConverter());
             options.Converters.Add(new JsonNonStringKeyDictionaryConverterFactory());
+            options.Converters.Add(new JsonInt64Converter());
 
             return options;
         }