diff options
Diffstat (limited to 'Jellyfin.Api/Controllers')
32 files changed, 2345 insertions, 116 deletions
diff --git a/Jellyfin.Api/Controllers/AlbumsController.cs b/Jellyfin.Api/Controllers/AlbumsController.cs index 622123873..70315b0a3 100644 --- a/Jellyfin.Api/Controllers/AlbumsController.cs +++ b/Jellyfin.Api/Controllers/AlbumsController.cs @@ -53,7 +53,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums( [FromRoute] string albumId, [FromQuery] Guid userId, - [FromQuery] string excludeArtistIds, + [FromQuery] string? excludeArtistIds, [FromQuery] int? limit) { var dtoOptions = new DtoOptions().AddClientFields(Request); @@ -85,7 +85,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists( [FromRoute] string artistId, [FromQuery] Guid userId, - [FromQuery] string excludeArtistIds, + [FromQuery] string? excludeArtistIds, [FromQuery] int? limit) { var dtoOptions = new DtoOptions().AddClientFields(Request); diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index ed521c1fc..fef4d7262 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Keys")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CreateKey([FromQuery, Required] string app) + public ActionResult CreateKey([FromQuery, Required] string? app) { _authRepo.Create(new AuthenticationInfo { @@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers [HttpDelete("Keys/{key}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RevokeKey([FromRoute] string key) + public ActionResult RevokeKey([FromRoute] string? key) { _sessionManager.RevokeToken(key); return NoContent(); diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 29db0b178..7ff98b251 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -51,8 +51,8 @@ namespace Jellyfin.Api.Controllers [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<CollectionCreationResult> CreateCollection( - [FromQuery] string name, - [FromQuery] string ids, + [FromQuery] string? name, + [FromQuery] string? ids, [FromQuery] bool isLocked, [FromQuery] Guid? parentId) { @@ -86,7 +86,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("{collectionId}/Items")] [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)); return NoContent(); @@ -101,7 +101,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpDelete("{collectionId}/Items")] [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)); return NoContent(); diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index d275ed2eb..13933cb33 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -70,7 +70,7 @@ namespace Jellyfin.Api.Controllers /// <returns>Configuration.</returns> [HttpGet("Configuration/{key}")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<object> GetNamedConfiguration([FromRoute] string key) + public ActionResult<object> GetNamedConfiguration([FromRoute] string? key) { return _configurationManager.GetConfiguration(key); } @@ -84,7 +84,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Configuration/{key}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key) + public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string? key) { var configurationType = _configurationManager.GetConfigurationType(key); var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 6cfee2463..699ef6bf7 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -122,7 +122,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("/web/ConfigurationPage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetDashboardConfigurationPage([FromQuery] string name) + public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) { IPlugin? plugin = null; Stream? stream = null; diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 55ca7b7c0..3cf7b3378 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string id) + public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string? id) { var deviceInfo = _deviceManager.GetDevice(id); if (deviceInfo == null) @@ -87,7 +87,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string id) + public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string? id) { var deviceInfo = _deviceManager.GetDeviceOptions(id); if (deviceInfo == null) @@ -111,7 +111,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateDeviceOptions( - [FromQuery, BindRequired] string id, + [FromQuery, BindRequired] string? id, [FromBody, BindRequired] DeviceOptions deviceOptions) { var existingDeviceOptions = _deviceManager.GetDeviceOptions(id); @@ -134,7 +134,7 @@ namespace Jellyfin.Api.Controllers [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteDevice([FromQuery, BindRequired] string id) + public ActionResult DeleteDevice([FromQuery, BindRequired] string? id) { var existingDevice = _deviceManager.GetDevice(id); if (existingDevice == null) diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 3f946d9d2..1255e6dab 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -39,9 +39,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("{displayPreferencesId}")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<DisplayPreferences> GetDisplayPreferences( - [FromRoute] string displayPreferencesId, - [FromQuery] [Required] string userId, - [FromQuery] [Required] string client) + [FromRoute] string? displayPreferencesId, + [FromQuery] [Required] string? userId, + [FromQuery] [Required] string? client) { return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client); } @@ -59,9 +59,9 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] public ActionResult UpdateDisplayPreferences( - [FromRoute] string displayPreferencesId, - [FromQuery, BindRequired] string userId, - [FromQuery, BindRequired] string client, + [FromRoute] string? displayPreferencesId, + [FromQuery, BindRequired] string? userId, + [FromQuery, BindRequired] string? client, [FromBody, BindRequired] DisplayPreferences displayPreferences) { _displayPreferencesRepository.SaveDisplayPreferences( diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs index 4800c0608..5244c35b8 100644 --- a/Jellyfin.Api/Controllers/ImageByNameController.cs +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -64,7 +64,7 @@ namespace Jellyfin.Api.Controllers [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string name, [FromRoute] string type) + public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string? name, [FromRoute] string? type) { var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase) ? "folder" @@ -110,8 +110,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<FileStreamResult> GetRatingImage( - [FromRoute] string theme, - [FromRoute] string name) + [FromRoute] string? theme, + [FromRoute] string? name) { return GetImageFile(_applicationPaths.RatingsPath, theme, name); } @@ -143,8 +143,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<FileStreamResult> GetMediaInfoImage( - [FromRoute] string theme, - [FromRoute] string name) + [FromRoute] string? theme, + [FromRoute] string? name) { return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name); } @@ -156,7 +156,7 @@ namespace Jellyfin.Api.Controllers /// <param name="theme">Theme to search.</param> /// <param name="name">File name to search for.</param> /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> - private ActionResult<FileStreamResult> GetImageFile(string basePath, string theme, string name) + private ActionResult<FileStreamResult> GetImageFile(string basePath, string? theme, string? name) { var themeFolder = Path.Combine(basePath, theme); if (Directory.Exists(themeFolder)) diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs new file mode 100644 index 000000000..9d945fe2b --- /dev/null +++ b/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; + } + } +} diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 384f250ec..c9b2aafcc 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Items/{itemId}/ContentType")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string contentType) + public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string? contentType) { var item = _libraryManager.GetItemById(itemId); if (item == null) diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 1ecf2ac73..f1106cda6 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -524,7 +524,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Library/Series/Updated")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedSeries([FromQuery] string tvdbId) + public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId) { var series = _libraryManager.GetItemList(new InternalItemsQuery { @@ -554,7 +554,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Library/Movies/Updated")] [Authorize(Policy = Policies.DefaultAuthorization)] [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 { @@ -687,10 +687,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( [FromRoute] Guid itemId, - [FromQuery] string excludeArtistIds, + [FromQuery] string? excludeArtistIds, [FromQuery] Guid userId, [FromQuery] int? limit, - [FromQuery] string fields) + [FromQuery] string? fields) { var item = itemId.Equals(Guid.Empty) ? (!userId.Equals(Guid.Empty) @@ -737,7 +737,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("/Libraries/AvailableOptions")] [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] [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(); @@ -877,10 +877,10 @@ namespace Jellyfin.Api.Controllers private QueryResult<BaseItemDto> GetSimilarItemsResult( BaseItem item, - string excludeArtistIds, + string? excludeArtistIds, Guid userId, int? limit, - string fields, + string? fields, string[] includeItemTypes, bool isMovie) { @@ -942,7 +942,7 @@ namespace Jellyfin.Api.Controllers return result; } - private static string[] GetRepresentativeItemTypes(string contentType) + private static string[] GetRepresentativeItemTypes(string? contentType) { return contentType switch { diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index e4ac019c9..0c91f8447 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -72,8 +72,8 @@ namespace Jellyfin.Api.Controllers [HttpPost] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddVirtualFolder( - [FromQuery] string name, - [FromQuery] string collectionType, + [FromQuery] string? name, + [FromQuery] string? collectionType, [FromQuery] bool refreshLibrary, [FromQuery] string[] paths, [FromQuery] LibraryOptions libraryOptions) @@ -100,7 +100,7 @@ namespace Jellyfin.Api.Controllers [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> RemoveVirtualFolder( - [FromQuery] string name, + [FromQuery] string? name, [FromQuery] bool refreshLibrary) { await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); @@ -123,8 +123,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] public ActionResult RenameVirtualFolder( - [FromQuery] string name, - [FromQuery] string newName, + [FromQuery] string? name, + [FromQuery] string? newName, [FromQuery] bool refreshLibrary) { if (string.IsNullOrWhiteSpace(name)) @@ -205,8 +205,8 @@ namespace Jellyfin.Api.Controllers [HttpPost("Paths")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult AddMediaPath( - [FromQuery] string name, - [FromQuery] string path, + [FromQuery] string? name, + [FromQuery] string? path, [FromQuery] MediaPathInfo pathInfo, [FromQuery] bool refreshLibrary) { @@ -256,7 +256,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Paths/Update")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateMediaPath( - [FromQuery] string name, + [FromQuery] string? name, [FromQuery] MediaPathInfo pathInfo) { if (string.IsNullOrWhiteSpace(name)) @@ -280,8 +280,8 @@ namespace Jellyfin.Api.Controllers [HttpDelete("Paths")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult RemoveMediaPath( - [FromQuery] string name, - [FromQuery] string path, + [FromQuery] string? name, + [FromQuery] string? path, [FromQuery] bool refreshLibrary) { if (string.IsNullOrWhiteSpace(name)) @@ -327,7 +327,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("LibraryOptions")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateLibraryOptions( - [FromQuery] string id, + [FromQuery] string? id, [FromQuery] LibraryOptions libraryOptions) { var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id); diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs new file mode 100644 index 000000000..daf4bf419 --- /dev/null +++ b/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(); + } + } +} diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs index cfa7545c9..02aa39b24 100644 --- a/Jellyfin.Api/Controllers/NotificationsController.cs +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -93,8 +93,8 @@ namespace Jellyfin.Api.Controllers [HttpPost("Admin")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult CreateAdminNotification( - [FromQuery] string name, - [FromQuery] string description, + [FromQuery] string? name, + [FromQuery] string? description, [FromQuery] string? url, [FromQuery] NotificationLevel? level) { diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 486575d23..68ae05658 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -40,7 +40,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("/{name}")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<PackageInfo>> GetPackageInfo( - [FromRoute] [Required] string name, + [FromRoute] [Required] string? name, [FromQuery] string? assemblyGuid) { var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); @@ -80,9 +80,9 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = Policies.RequiresElevation)] public async Task<ActionResult> InstallPackage( - [FromRoute] [Required] string name, - [FromQuery] string assemblyGuid, - [FromQuery] string version) + [FromRoute] [Required] string? name, + [FromQuery] string? assemblyGuid, + [FromQuery] string? version) { var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var package = _installationManager.GetCompatibleVersions( diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 2dc0d2dc7..d62404fc9 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -84,8 +84,8 @@ namespace Jellyfin.Api.Controllers [HttpPost("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult AddToPlaylist( - [FromRoute] string playlistId, - [FromQuery] string ids, + [FromRoute] string? playlistId, + [FromQuery] string? ids, [FromQuery] Guid userId) { _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId); @@ -103,8 +103,8 @@ namespace Jellyfin.Api.Controllers [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult MoveItem( - [FromRoute] string playlistId, - [FromRoute] string itemId, + [FromRoute] string? playlistId, + [FromRoute] string? itemId, [FromRoute] int newIndex) { _playlistManager.MoveItem(playlistId, itemId, newIndex); @@ -120,7 +120,7 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="NoContentResult"/> on success.</returns> [HttpDelete("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveFromPlaylist([FromRoute] string playlistId, [FromQuery] string entryIds) + public ActionResult RemoveFromPlaylist([FromRoute] string? playlistId, [FromQuery] string? entryIds) { _playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true)); return NoContent(); @@ -147,11 +147,11 @@ namespace Jellyfin.Api.Controllers [FromRoute] Guid userId, [FromRoute] int? startIndex, [FromRoute] int? limit, - [FromRoute] string fields, + [FromRoute] string? fields, [FromRoute] bool? enableImages, [FromRoute] bool? enableUserData, [FromRoute] int? imageTypeLimit, - [FromRoute] string enableImageTypes) + [FromRoute] string? enableImageTypes) { var playlist = (Playlist)_libraryManager.GetItemById(playlistId); if (playlist == null) diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs new file mode 100644 index 000000000..05a6edf4e --- /dev/null +++ b/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; + } + } +} diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index fd48983ea..056395a51 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -166,7 +166,7 @@ namespace Jellyfin.Api.Controllers [Obsolete("This endpoint should not be used.")] [HttpPost("RegistrationRecords/{name}")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string name) + public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string? name) { return new MBRegistrationRecord { @@ -188,7 +188,7 @@ namespace Jellyfin.Api.Controllers [Obsolete("Paid plugins are not supported")] [HttpGet("/Registrations/{name}")] [ProducesResponseType(StatusCodes.Status501NotImplemented)] - public ActionResult GetRegistration([FromRoute] string name) + public ActionResult GetRegistration([FromRoute] string? name) { // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, // delete all these registration endpoints. They are only kept for compatibility. diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index a0d14be7a..6fff30129 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -208,7 +208,7 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> DownloadRemoteImage( [FromRoute] Guid itemId, [FromQuery, BindRequired] ImageType type, - [FromQuery] string imageUrl) + [FromQuery] string? imageUrl) { var item = _libraryManager.GetItemById(itemId); if (item == null) diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs index bf5c3076e..3df325e3b 100644 --- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -71,7 +71,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("{taskId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<TaskInfo> GetTask([FromRoute] string taskId) + public ActionResult<TaskInfo> GetTask([FromRoute] string? taskId) { var task = _taskManager.ScheduledTasks.FirstOrDefault(i => string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); @@ -94,7 +94,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Running/{taskId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult StartTask([FromRoute] string taskId) + public ActionResult StartTask([FromRoute] string? taskId) { var task = _taskManager.ScheduledTasks.FirstOrDefault(o => o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); @@ -118,7 +118,7 @@ namespace Jellyfin.Api.Controllers [HttpDelete("Running/{taskId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult StopTask([FromRoute] string taskId) + public ActionResult StopTask([FromRoute] string? taskId) { var task = _taskManager.ScheduledTasks.FirstOrDefault(o => o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); @@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateTask( - [FromRoute] string taskId, + [FromRoute] string? taskId, [FromBody, BindRequired] TaskTriggerInfo[] triggerInfos) { var task = _taskManager.ScheduledTasks.FirstOrDefault(o => diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index d971889db..14dc0815c 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -81,11 +81,11 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] Guid userId, - [FromQuery, Required] string searchTerm, - [FromQuery] string includeItemTypes, - [FromQuery] string excludeItemTypes, - [FromQuery] string mediaTypes, - [FromQuery] string parentId, + [FromQuery, Required] string? searchTerm, + [FromQuery] string? includeItemTypes, + [FromQuery] string? excludeItemTypes, + [FromQuery] string? mediaTypes, + [FromQuery] string? parentId, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, [FromQuery] bool? isNews, diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 39da4178d..bd738aa38 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -62,7 +62,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<SessionInfo>> GetSessions( [FromQuery] Guid controllableByUserId, - [FromQuery] string deviceId, + [FromQuery] string? deviceId, [FromQuery] int? activeWithinSeconds) { var result = _sessionManager.Sessions; @@ -123,10 +123,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/Viewing")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DisplayContent( - [FromRoute] string sessionId, - [FromQuery] string itemType, - [FromQuery] string itemId, - [FromQuery] string itemName) + [FromRoute] string? sessionId, + [FromQuery] string? itemType, + [FromQuery] string? itemId, + [FromQuery] string? itemName) { var command = new BrowseRequest { @@ -157,7 +157,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/Playing")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult Play( - [FromRoute] string sessionId, + [FromRoute] string? sessionId, [FromQuery] Guid[] itemIds, [FromQuery] long? startPositionTicks, [FromQuery] PlayCommand playCommand, @@ -191,7 +191,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/Playing/{command}")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SendPlaystateCommand( - [FromRoute] string sessionId, + [FromRoute] string? sessionId, [FromBody] PlaystateRequest playstateRequest) { _sessionManager.SendPlaystateCommand( @@ -213,8 +213,8 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/System/{command}")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SendSystemCommand( - [FromRoute] string sessionId, - [FromRoute] string command) + [FromRoute] string? sessionId, + [FromRoute] string? command) { var name = command; if (Enum.TryParse(name, true, out GeneralCommandType commandType)) @@ -244,8 +244,8 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/Command/{Command}")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SendGeneralCommand( - [FromRoute] string sessionId, - [FromRoute] string command) + [FromRoute] string? sessionId, + [FromRoute] string? command) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); @@ -270,7 +270,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/Command")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SendFullGeneralCommand( - [FromRoute] string sessionId, + [FromRoute] string? sessionId, [FromBody, Required] GeneralCommand command) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); @@ -303,9 +303,9 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/Message")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SendMessageCommand( - [FromRoute] string sessionId, - [FromQuery] string text, - [FromQuery] string header, + [FromRoute] string? sessionId, + [FromQuery] string? text, + [FromQuery] string? header, [FromQuery] long? timeoutMs) { var command = new MessageCommand @@ -330,7 +330,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/{sessionId}/User/{userId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult AddUserToSession( - [FromRoute] string sessionId, + [FromRoute] string? sessionId, [FromRoute] Guid userId) { _sessionManager.AddAdditionalUser(sessionId, userId); @@ -347,7 +347,7 @@ namespace Jellyfin.Api.Controllers [HttpDelete("/Sessions/{sessionId}/User/{userId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult RemoveUserFromSession( - [FromRoute] string sessionId, + [FromRoute] string? sessionId, [FromRoute] Guid userId) { _sessionManager.RemoveAdditionalUser(sessionId, userId); @@ -368,9 +368,9 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/Capabilities")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostCapabilities( - [FromQuery] string id, - [FromQuery] string playableMediaTypes, - [FromQuery] string supportedCommands, + [FromQuery] string? id, + [FromQuery] string? playableMediaTypes, + [FromQuery] string? supportedCommands, [FromQuery] bool supportsMediaControl, [FromQuery] bool supportsSync, [FromQuery] bool supportsPersistentIdentifier = true) @@ -401,7 +401,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/Capabilities/Full")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostFullCapabilities( - [FromQuery] string id, + [FromQuery] string? id, [FromBody, Required] ClientCapabilities capabilities) { if (string.IsNullOrWhiteSpace(id)) @@ -424,8 +424,8 @@ namespace Jellyfin.Api.Controllers [HttpPost("/Sessions/Viewing")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult ReportViewing( - [FromQuery] string sessionId, - [FromQuery] string itemId) + [FromQuery] string? sessionId, + [FromQuery] string? itemId) { string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index d96b0f993..cc1f797b1 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -75,9 +75,9 @@ namespace Jellyfin.Api.Controllers [HttpPost("Configuration")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateInitialConfiguration( - [FromForm] string uiCulture, - [FromForm] string metadataCountryCode, - [FromForm] string preferredMetadataLanguage) + [FromForm] string? uiCulture, + [FromForm] string? metadataCountryCode, + [FromForm] string? preferredMetadataLanguage) { _config.Configuration.UICulture = uiCulture; _config.Configuration.MetadataCountryCode = metadataCountryCode; diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 95cc39524..baedafaa6 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -112,7 +112,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( [FromRoute] Guid itemId, - [FromRoute] string language, + [FromRoute] string? language, [FromQuery] bool? isPerfectMatch) { var video = (Video)_libraryManager.GetItemById(itemId); @@ -132,7 +132,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> DownloadRemoteSubtitles( [FromRoute] Guid itemId, - [FromRoute] string subtitleId) + [FromRoute] string? subtitleId) { var video = (Video)_libraryManager.GetItemById(itemId); @@ -161,7 +161,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [Produces(MediaTypeNames.Application.Octet)] - public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string id) + public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string? id) { var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); @@ -186,9 +186,9 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult> GetSubtitle( [FromRoute, Required] Guid itemId, - [FromRoute, Required] string mediaSourceId, + [FromRoute, Required] string? mediaSourceId, [FromRoute, Required] int index, - [FromRoute, Required] string format, + [FromRoute, Required] string? format, [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps, [FromQuery] bool addVttTimeMap, @@ -254,7 +254,7 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> GetSubtitlePlaylist( [FromRoute] Guid itemId, [FromRoute] int index, - [FromRoute] string mediaSourceId, + [FromRoute] string? mediaSourceId, [FromQuery, Required] int segmentLength) { var item = (Video)_libraryManager.GetItemById(itemId); @@ -324,7 +324,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> private Task<Stream> EncodeSubtitles( Guid id, - string mediaSourceId, + string? mediaSourceId, int index, string format, long startPositionTicks, diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index e33821b24..bc606f7aa 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Logs/Log")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetLogFile([FromQuery, Required] string name) + public ActionResult GetLogFile([FromQuery, Required] string? name) { var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 6738dd8c8..80b6a2488 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -190,7 +190,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( - [FromRoute] string seriesId, + [FromRoute] string? seriesId, [FromQuery] Guid userId, [FromQuery] string? fields, [FromQuery] int? season, @@ -311,12 +311,12 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetSeasons( - [FromRoute] string seriesId, + [FromRoute] string? seriesId, [FromQuery] Guid userId, - [FromQuery] string fields, + [FromQuery] string? fields, [FromQuery] bool? isSpecialSeason, [FromQuery] bool? isMissing, - [FromQuery] string adjacentTo, + [FromQuery] string? adjacentTo, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, [FromQuery] string? enableImageTypes, diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 9f8d564a7..24194dcc2 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -164,8 +164,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( [FromRoute, Required] Guid userId, - [FromQuery, BindRequired] string pw, - [FromQuery, BindRequired] string password) + [FromQuery, BindRequired] string? pw, + [FromQuery, BindRequired] string? password) { var user = _userManager.GetUserById(userId); @@ -483,7 +483,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> [HttpPost("ForgotPassword")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string enteredUsername) + public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string? enteredUsername) { var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress) || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString()); @@ -501,7 +501,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> [HttpPost("ForgotPassword/Pin")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string pin) + public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string? pin) { var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false); return result; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs new file mode 100644 index 000000000..ca804ebc9 --- /dev/null +++ b/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); + } + } +} diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs new file mode 100644 index 000000000..ad8927262 --- /dev/null +++ b/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)); + } + } +} diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index 943ba8af3..eef0a93cd 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -50,7 +50,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<FileStreamResult>> GetAttachment( [FromRoute] Guid videoId, - [FromRoute] string mediaSourceId, + [FromRoute] string? mediaSourceId, [FromRoute] int index) { try diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index effe630a9..fb1141984 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult MergeVersions([FromQuery] string itemIds) + public ActionResult MergeVersions([FromQuery] string? itemIds) { var items = RequestHelpers.Split(itemIds, ',', true) .Select(i => _libraryManager.GetItemById(i)) diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs new file mode 100644 index 000000000..a66a3951e --- /dev/null +++ b/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)); + } + } +} |
