Browse Source

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

crobibero 5 years ago
parent
commit
da40572979
46 changed files with 3229 additions and 1631 deletions
  1. 2 2
      Jellyfin.Api/Controllers/AlbumsController.cs
  2. 2 2
      Jellyfin.Api/Controllers/ApiKeyController.cs
  3. 4 4
      Jellyfin.Api/Controllers/CollectionController.cs
  4. 2 2
      Jellyfin.Api/Controllers/ConfigurationController.cs
  5. 1 1
      Jellyfin.Api/Controllers/DashboardController.cs
  6. 4 4
      Jellyfin.Api/Controllers/DevicesController.cs
  7. 6 6
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  8. 6 6
      Jellyfin.Api/Controllers/ImageByNameController.cs
  9. 314 0
      Jellyfin.Api/Controllers/InstantMixController.cs
  10. 1 1
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  11. 8 8
      Jellyfin.Api/Controllers/LibraryController.cs
  12. 11 11
      Jellyfin.Api/Controllers/LibraryStructureController.cs
  13. 773 0
      Jellyfin.Api/Controllers/MediaInfoController.cs
  14. 2 2
      Jellyfin.Api/Controllers/NotificationsController.cs
  15. 4 4
      Jellyfin.Api/Controllers/PackageController.cs
  16. 7 7
      Jellyfin.Api/Controllers/PlaylistsController.cs
  17. 372 0
      Jellyfin.Api/Controllers/PlaystateController.cs
  18. 2 2
      Jellyfin.Api/Controllers/PluginsController.cs
  19. 1 1
      Jellyfin.Api/Controllers/RemoteImageController.cs
  20. 4 4
      Jellyfin.Api/Controllers/ScheduledTasksController.cs
  21. 5 5
      Jellyfin.Api/Controllers/SearchController.cs
  22. 23 23
      Jellyfin.Api/Controllers/SessionController.cs
  23. 3 3
      Jellyfin.Api/Controllers/StartupController.cs
  24. 7 7
      Jellyfin.Api/Controllers/SubtitleController.cs
  25. 1 1
      Jellyfin.Api/Controllers/SystemController.cs
  26. 4 4
      Jellyfin.Api/Controllers/TvShowsController.cs
  27. 4 4
      Jellyfin.Api/Controllers/UserController.cs
  28. 391 0
      Jellyfin.Api/Controllers/UserLibraryController.cs
  29. 148 0
      Jellyfin.Api/Controllers/UserViewsController.cs
  30. 1 1
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  31. 1 1
      Jellyfin.Api/Controllers/VideosController.cs
  32. 231 0
      Jellyfin.Api/Controllers/YearsController.cs
  33. 2 2
      Jellyfin.Api/Extensions/DtoExtensions.cs
  34. 40 1
      Jellyfin.Api/Helpers/RequestHelpers.cs
  35. 1 1
      Jellyfin.Api/Helpers/SimilarItemsHelper.cs
  36. 354 0
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  37. 256 0
      Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs
  38. 212 0
      Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs
  39. 18 0
      Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs
  40. 1 1
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  41. 0 197
      MediaBrowser.Api/Music/InstantMixService.cs
  42. 0 5
      MediaBrowser.Api/Playback/MediaInfoService.cs
  43. 0 456
      MediaBrowser.Api/UserLibrary/PlaystateService.cs
  44. 0 575
      MediaBrowser.Api/UserLibrary/UserLibraryService.cs
  45. 0 146
      MediaBrowser.Api/UserLibrary/UserViewsService.cs
  46. 0 131
      MediaBrowser.Api/UserLibrary/YearsService.cs

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

@@ -53,7 +53,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums(
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums(
             [FromRoute] string albumId,
             [FromRoute] string albumId,
             [FromQuery] Guid userId,
             [FromQuery] Guid userId,
-            [FromQuery] string excludeArtistIds,
+            [FromQuery] string? excludeArtistIds,
             [FromQuery] int? limit)
             [FromQuery] int? limit)
         {
         {
             var dtoOptions = new DtoOptions().AddClientFields(Request);
             var dtoOptions = new DtoOptions().AddClientFields(Request);
@@ -85,7 +85,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists(
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists(
             [FromRoute] string artistId,
             [FromRoute] string artistId,
             [FromQuery] Guid userId,
             [FromQuery] Guid userId,
-            [FromQuery] string excludeArtistIds,
+            [FromQuery] string? excludeArtistIds,
             [FromQuery] int? limit)
             [FromQuery] int? limit)
         {
         {
             var dtoOptions = new DtoOptions().AddClientFields(Request);
             var dtoOptions = new DtoOptions().AddClientFields(Request);

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

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

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

@@ -51,8 +51,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost]
         [HttpPost]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<CollectionCreationResult> CreateCollection(
         public ActionResult<CollectionCreationResult> CreateCollection(
-            [FromQuery] string name,
-            [FromQuery] string ids,
+            [FromQuery] string? name,
+            [FromQuery] string? ids,
             [FromQuery] bool isLocked,
             [FromQuery] bool isLocked,
             [FromQuery] Guid? parentId)
             [FromQuery] Guid? parentId)
         {
         {
@@ -86,7 +86,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpPost("{collectionId}/Items")]
         [HttpPost("{collectionId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery] string itemIds)
+        public ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery] string? itemIds)
         {
         {
             _collectionManager.AddToCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
             _collectionManager.AddToCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
             return NoContent();
             return NoContent();
@@ -101,7 +101,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
         [HttpDelete("{collectionId}/Items")]
         [HttpDelete("{collectionId}/Items")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery] string itemIds)
+        public ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery] string? itemIds)
         {
         {
             _collectionManager.RemoveFromCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
             _collectionManager.RemoveFromCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
             return NoContent();
             return NoContent();

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

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

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

@@ -122,7 +122,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("/web/ConfigurationPage")]
         [HttpGet("/web/ConfigurationPage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult GetDashboardConfigurationPage([FromQuery] string name)
+        public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
         {
         {
             IPlugin? plugin = null;
             IPlugin? plugin = null;
             Stream? stream = null;
             Stream? stream = null;

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

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

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

@@ -39,9 +39,9 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("{displayPreferencesId}")]
         [HttpGet("{displayPreferencesId}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<DisplayPreferences> GetDisplayPreferences(
         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);
             return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client);
         }
         }
@@ -59,9 +59,9 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
         public ActionResult UpdateDisplayPreferences(
         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)
             [FromBody, BindRequired] DisplayPreferences displayPreferences)
         {
         {
             _displayPreferencesRepository.SaveDisplayPreferences(
             _displayPreferencesRepository.SaveDisplayPreferences(

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

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

@@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Items/{itemId}/ContentType")]
         [HttpPost("/Items/{itemId}/ContentType")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [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);
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
             if (item == null)

+ 8 - 8
Jellyfin.Api/Controllers/LibraryController.cs

@@ -524,7 +524,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Library/Series/Updated")]
         [HttpPost("/Library/Series/Updated")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult PostUpdatedSeries([FromQuery] string tvdbId)
+        public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId)
         {
         {
             var series = _libraryManager.GetItemList(new InternalItemsQuery
             var series = _libraryManager.GetItemList(new InternalItemsQuery
             {
             {
@@ -554,7 +554,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("/Library/Movies/Updated")]
         [HttpPost("/Library/Movies/Updated")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
-        public ActionResult PostUpdatedMovies([FromRoute] string tmdbId, [FromRoute] string imdbId)
+        public ActionResult PostUpdatedMovies([FromRoute] string? tmdbId, [FromRoute] string? imdbId)
         {
         {
             var movies = _libraryManager.GetItemList(new InternalItemsQuery
             var movies = _libraryManager.GetItemList(new InternalItemsQuery
             {
             {
@@ -687,10 +687,10 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
         public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
             [FromRoute] Guid itemId,
             [FromRoute] Guid itemId,
-            [FromQuery] string excludeArtistIds,
+            [FromQuery] string? excludeArtistIds,
             [FromQuery] Guid userId,
             [FromQuery] Guid userId,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
-            [FromQuery] string fields)
+            [FromQuery] string? fields)
         {
         {
             var item = itemId.Equals(Guid.Empty)
             var item = itemId.Equals(Guid.Empty)
                 ? (!userId.Equals(Guid.Empty)
                 ? (!userId.Equals(Guid.Empty)
@@ -737,7 +737,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("/Libraries/AvailableOptions")]
         [HttpGet("/Libraries/AvailableOptions")]
         [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
         [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo([FromQuery] string libraryContentType, [FromQuery] bool isNewLibrary)
+        public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo([FromQuery] string? libraryContentType, [FromQuery] bool isNewLibrary)
         {
         {
             var result = new LibraryOptionsResultDto();
             var result = new LibraryOptionsResultDto();
 
 
@@ -877,10 +877,10 @@ namespace Jellyfin.Api.Controllers
 
 
         private QueryResult<BaseItemDto> GetSimilarItemsResult(
         private QueryResult<BaseItemDto> GetSimilarItemsResult(
             BaseItem item,
             BaseItem item,
-            string excludeArtistIds,
+            string? excludeArtistIds,
             Guid userId,
             Guid userId,
             int? limit,
             int? limit,
-            string fields,
+            string? fields,
             string[] includeItemTypes,
             string[] includeItemTypes,
             bool isMovie)
             bool isMovie)
         {
         {
@@ -942,7 +942,7 @@ namespace Jellyfin.Api.Controllers
             return result;
             return result;
         }
         }
 
 
-        private static string[] GetRepresentativeItemTypes(string contentType)
+        private static string[] GetRepresentativeItemTypes(string? contentType)
         {
         {
             return contentType switch
             return contentType switch
             {
             {

+ 11 - 11
Jellyfin.Api/Controllers/LibraryStructureController.cs

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

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

@@ -93,8 +93,8 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Admin")]
         [HttpPost("Admin")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult CreateAdminNotification(
         public ActionResult CreateAdminNotification(
-            [FromQuery] string name,
-            [FromQuery] string description,
+            [FromQuery] string? name,
+            [FromQuery] string? description,
             [FromQuery] string? url,
             [FromQuery] string? url,
             [FromQuery] NotificationLevel? level)
             [FromQuery] NotificationLevel? level)
         {
         {

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

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

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

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

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

@@ -166,7 +166,7 @@ namespace Jellyfin.Api.Controllers
         [Obsolete("This endpoint should not be used.")]
         [Obsolete("This endpoint should not be used.")]
         [HttpPost("RegistrationRecords/{name}")]
         [HttpPost("RegistrationRecords/{name}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string name)
+        public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string? name)
         {
         {
             return new MBRegistrationRecord
             return new MBRegistrationRecord
             {
             {
@@ -188,7 +188,7 @@ namespace Jellyfin.Api.Controllers
         [Obsolete("Paid plugins are not supported")]
         [Obsolete("Paid plugins are not supported")]
         [HttpGet("/Registrations/{name}")]
         [HttpGet("/Registrations/{name}")]
         [ProducesResponseType(StatusCodes.Status501NotImplemented)]
         [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,
             // 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.
             // 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(
         public async Task<ActionResult> DownloadRemoteImage(
             [FromRoute] Guid itemId,
             [FromRoute] Guid itemId,
             [FromQuery, BindRequired] ImageType type,
             [FromQuery, BindRequired] ImageType type,
-            [FromQuery] string imageUrl)
+            [FromQuery] string? imageUrl)
         {
         {
             var item = _libraryManager.GetItemById(itemId);
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
             if (item == null)

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

@@ -71,7 +71,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("{taskId}")]
         [HttpGet("{taskId}")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult<TaskInfo> GetTask([FromRoute] string taskId)
+        public ActionResult<TaskInfo> GetTask([FromRoute] string? taskId)
         {
         {
             var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
             var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
                 string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
                 string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
@@ -94,7 +94,7 @@ namespace Jellyfin.Api.Controllers
         [HttpPost("Running/{taskId}")]
         [HttpPost("Running/{taskId}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult StartTask([FromRoute] string taskId)
+        public ActionResult StartTask([FromRoute] string? taskId)
         {
         {
             var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
             var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
                 o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
                 o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
@@ -118,7 +118,7 @@ namespace Jellyfin.Api.Controllers
         [HttpDelete("Running/{taskId}")]
         [HttpDelete("Running/{taskId}")]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
-        public ActionResult StopTask([FromRoute] string taskId)
+        public ActionResult StopTask([FromRoute] string? taskId)
         {
         {
             var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
             var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
                 o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
                 o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
@@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult UpdateTask(
         public ActionResult UpdateTask(
-            [FromRoute] string taskId,
+            [FromRoute] string? taskId,
             [FromBody, BindRequired] TaskTriggerInfo[] triggerInfos)
             [FromBody, BindRequired] TaskTriggerInfo[] triggerInfos)
         {
         {
             var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
             var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>

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

@@ -81,11 +81,11 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? startIndex,
             [FromQuery] int? startIndex,
             [FromQuery] int? limit,
             [FromQuery] int? limit,
             [FromQuery] Guid userId,
             [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? isMovie,
             [FromQuery] bool? isSeries,
             [FromQuery] bool? isSeries,
             [FromQuery] bool? isNews,
             [FromQuery] bool? isNews,

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

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

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

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

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

@@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("Logs/Log")]
         [HttpGet("Logs/Log")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public ActionResult GetLogFile([FromQuery, Required] string name)
+        public ActionResult GetLogFile([FromQuery, Required] string? name)
         {
         {
             var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
             var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
                 .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
                 .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));

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

@@ -190,7 +190,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
         public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
-            [FromRoute] string seriesId,
+            [FromRoute] string? seriesId,
             [FromQuery] Guid userId,
             [FromQuery] Guid userId,
             [FromQuery] string? fields,
             [FromQuery] string? fields,
             [FromQuery] int? season,
             [FromQuery] int? season,
@@ -311,12 +311,12 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
         public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
-            [FromRoute] string seriesId,
+            [FromRoute] string? seriesId,
             [FromQuery] Guid userId,
             [FromQuery] Guid userId,
-            [FromQuery] string fields,
+            [FromQuery] string? fields,
             [FromQuery] bool? isSpecialSeason,
             [FromQuery] bool? isSpecialSeason,
             [FromQuery] bool? isMissing,
             [FromQuery] bool? isMissing,
-            [FromQuery] string adjacentTo,
+            [FromQuery] string? adjacentTo,
             [FromQuery] bool? enableImages,
             [FromQuery] bool? enableImages,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] int? imageTypeLimit,
             [FromQuery] string? enableImageTypes,
             [FromQuery] string? enableImageTypes,

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

@@ -164,8 +164,8 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
         public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
             [FromRoute, Required] Guid userId,
             [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);
             var user = _userManager.GetUserById(userId);
 
 
@@ -483,7 +483,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
         /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
         [HttpPost("ForgotPassword")]
         [HttpPost("ForgotPassword")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [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)
             var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress)
                           || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString());
                           || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString());
@@ -501,7 +501,7 @@ namespace Jellyfin.Api.Controllers
         /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
         /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
         [HttpPost("ForgotPassword/Pin")]
         [HttpPost("ForgotPassword/Pin")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [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);
             var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false);
             return result;
             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)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult<FileStreamResult>> GetAttachment(
         public async Task<ActionResult<FileStreamResult>> GetAttachment(
             [FromRoute] Guid videoId,
             [FromRoute] Guid videoId,
-            [FromRoute] string mediaSourceId,
+            [FromRoute] string? mediaSourceId,
             [FromRoute] int index)
             [FromRoute] int index)
         {
         {
             try
             try

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

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

+ 2 - 2
Jellyfin.Api/Extensions/DtoExtensions.cs

@@ -23,7 +23,7 @@ namespace Jellyfin.Api.Extensions
         /// <param name="dtoOptions">DtoOptions object.</param>
         /// <param name="dtoOptions">DtoOptions object.</param>
         /// <param name="fields">Comma delimited string of fields.</param>
         /// <param name="fields">Comma delimited string of fields.</param>
         /// <returns>Modified DtoOptions object.</returns>
         /// <returns>Modified DtoOptions object.</returns>
-        internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string fields)
+        internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields)
         {
         {
             if (string.IsNullOrEmpty(fields))
             if (string.IsNullOrEmpty(fields))
             {
             {
@@ -126,7 +126,7 @@ namespace Jellyfin.Api.Extensions
             bool? enableImages,
             bool? enableImages,
             bool? enableUserData,
             bool? enableUserData,
             int? imageTypeLimit,
             int? imageTypeLimit,
-            string enableImageTypes)
+            string? enableImageTypes)
         {
         {
             dtoOptions.EnableImages = enableImages ?? true;
             dtoOptions.EnableImages = enableImages ?? true;
 
 

+ 40 - 1
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -4,6 +4,7 @@ using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Entities;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http;
 
 
 namespace Jellyfin.Api.Helpers
 namespace Jellyfin.Api.Helpers
@@ -20,7 +21,7 @@ namespace Jellyfin.Api.Helpers
         /// <param name="separator">The char that separates the substrings.</param>
         /// <param name="separator">The char that separates the substrings.</param>
         /// <param name="removeEmpty">Option to remove empty substrings from the array.</param>
         /// <param name="removeEmpty">Option to remove empty substrings from the array.</param>
         /// <returns>An array of the substrings.</returns>
         /// <returns>An array of the substrings.</returns>
-        internal static string[] Split(string value, char separator, bool removeEmpty)
+        internal static string[] Split(string? value, char separator, bool removeEmpty)
         {
         {
             if (string.IsNullOrWhiteSpace(value))
             if (string.IsNullOrWhiteSpace(value))
             {
             {
@@ -93,6 +94,44 @@ namespace Jellyfin.Api.Helpers
                 .ToArray();
                 .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)
+        {
+            if (string.IsNullOrEmpty(sortBy))
+            {
+                return Array.Empty<ValueTuple<string, SortOrder>>();
+            }
+
+            var vals = sortBy.Split(',');
+            if (string.IsNullOrWhiteSpace(requestedSortOrder))
+            {
+                requestedSortOrder = "Ascending";
+            }
+
+            var sortOrders = requestedSortOrder.Split(',');
+
+            var result = new ValueTuple<string, SortOrder>[vals.Length];
+
+            for (var i = 0; i < vals.Length; i++)
+            {
+                var sortOrderIndex = sortOrders.Length > i ? i : 0;
+
+                var sortOrderValue = sortOrders.Length > sortOrderIndex ? sortOrders[sortOrderIndex] : null;
+                var sortOrder = string.Equals(sortOrderValue, "Descending", StringComparison.OrdinalIgnoreCase)
+                    ? SortOrder.Descending
+                    : SortOrder.Ascending;
+
+                result[i] = new ValueTuple<string, SortOrder>(vals[i], sortOrder);
+            }
+
+            return result;
+        }
+
         /// <summary>
         /// <summary>
         /// Gets the filters.
         /// Gets the filters.
         /// </summary>
         /// </summary>

+ 1 - 1
Jellyfin.Api/Helpers/SimilarItemsHelper.cs

@@ -23,7 +23,7 @@ namespace Jellyfin.Api.Helpers
             IDtoService dtoService,
             IDtoService dtoService,
             Guid userId,
             Guid userId,
             string id,
             string id,
-            string excludeArtistIds,
+            string? excludeArtistIds,
             int? limit,
             int? limit,
             Type[] includeTypes,
             Type[] includeTypes,
             Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)
             Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore)

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

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

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

@@ -195,7 +195,7 @@ namespace Jellyfin.Server.Extensions
 
 
                 // Order actions by route path, then by http method.
                 // Order actions by route path, then by http method.
                 c.OrderActionsBy(description =>
                 c.OrderActionsBy(description =>
-                    $"{description.ActionDescriptor.RouteValues["controller"]}_{description.HttpMethod}");
+                    $"{description.ActionDescriptor.RouteValues["controller"]}_{description.RelativePath}");
 
 
                 // Use method name as operationId
                 // Use method name as operationId
                 c.CustomOperationIds(description =>
                 c.CustomOperationIds(description =>

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

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