using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.PlaylistDtos; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Playlists; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Jellyfin.Api.Controllers; /// /// Playlists controller. /// [Authorize] public class PlaylistsController : BaseJellyfinApiController { private readonly IPlaylistManager _playlistManager; private readonly IDtoService _dtoService; private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public PlaylistsController( IDtoService dtoService, IPlaylistManager playlistManager, IUserManager userManager, ILibraryManager libraryManager) { _dtoService = dtoService; _playlistManager = playlistManager; _userManager = userManager; _libraryManager = libraryManager; } /// /// Creates a new playlist. /// /// /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. /// Query parameters are obsolete. /// /// The playlist name. /// The item ids. /// The user id. /// The media type. /// The create playlist payload. /// Playlist created. /// /// A that represents the asynchronous operation to create a playlist. /// The task result contains an indicating success. /// [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CreatePlaylist( [FromQuery, ParameterObsolete] string? name, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList ids, [FromQuery, ParameterObsolete] Guid? userId, [FromQuery, ParameterObsolete] MediaType? mediaType, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) { if (ids.Count == 0) { ids = createPlaylistRequest?.Ids ?? Array.Empty(); } userId ??= createPlaylistRequest?.UserId ?? default; userId = RequestHelpers.GetUserId(User, userId); var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { Name = name ?? createPlaylistRequest?.Name, ItemIdList = ids, UserId = userId.Value, MediaType = mediaType ?? createPlaylistRequest?.MediaType, Users = createPlaylistRequest?.Users.ToArray() ?? [], Public = createPlaylistRequest?.Public }).ConfigureAwait(false); return result; } /// /// Updates a playlist. /// /// The playlist id. /// The id. /// Playlist updated. /// Unauthorized access. /// Playlist not found. /// /// A that represents the asynchronous operation to update a playlist. /// The task result contains an indicating success. /// [HttpPost("{playlistId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task UpdatePlaylist( [FromRoute, Required] Guid playlistId, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] UpdatePlaylistDto updatePlaylistRequest) { var callingUserId = User.GetUserId(); var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); if (playlist is null) { return NotFound("Playlist not found"); } var isPermitted = playlist.OwnerUserId.Equals(callingUserId) || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); if (!isPermitted) { return Unauthorized("Unauthorized access"); } await _playlistManager.UpdatePlaylist(new PlaylistUpdateRequest { UserId = callingUserId, Id = playlistId, Name = updatePlaylistRequest.Name, Ids = updatePlaylistRequest.Ids, Users = updatePlaylistRequest.Users, Public = updatePlaylistRequest.Public }).ConfigureAwait(false); return NoContent(); } /// /// Get a playlist's users. /// /// The playlist id. /// Found shares. /// Unauthorized access. /// Playlist not found. /// /// A list of objects. /// [HttpGet("{playlistId}/User")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult> GetPlaylistUsers( [FromRoute, Required] Guid playlistId) { var userId = User.GetUserId(); var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId); if (playlist is null) { return NotFound("Playlist not found"); } var isPermitted = playlist.OwnerUserId.Equals(userId) || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(userId)); return isPermitted ? playlist.Shares.ToList() : Unauthorized("Unauthorized Access"); } /// /// Modify a user to a playlist's users. /// /// The playlist id. /// The user id. /// The . /// User's permissions modified. /// Unauthorized access. /// Playlist not found. /// /// A that represents the asynchronous operation to modify an user's playlist permissions. /// The task result contains an indicating success. /// [HttpPost("{playlistId}/User/{userId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task UpdatePlaylistUser( [FromRoute, Required] Guid playlistId, [FromRoute, Required] Guid userId, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] UpdatePlaylistUserDto updatePlaylistUserRequest) { var callingUserId = User.GetUserId(); var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); if (playlist is null) { return NotFound("Playlist not found"); } var isPermitted = playlist.OwnerUserId.Equals(callingUserId) || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); if (!isPermitted) { return Unauthorized("Unauthorized access"); } await _playlistManager.AddUserToShares(new PlaylistUserUpdateRequest { Id = playlistId, UserId = userId, CanEdit = updatePlaylistUserRequest.CanEdit }).ConfigureAwait(false); return NoContent(); } /// /// Remove a user from a playlist's shares. /// /// The playlist id. /// The user id. /// User permissions removed from playlist. /// Unauthorized access. /// No playlist or user permissions found. /// /// A that represents the asynchronous operation to delete a user from a playlist's shares. /// The task result contains an indicating success. /// [HttpDelete("{playlistId}/User/{userId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task RemoveUserFromPlaylist( [FromRoute, Required] Guid playlistId, [FromRoute, Required] Guid userId) { var callingUserId = User.GetUserId(); var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); if (playlist is null) { return NotFound("Playlist not found"); } var isPermitted = playlist.OwnerUserId.Equals(callingUserId) || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); if (!isPermitted) { return Unauthorized("Unauthorized access"); } var share = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId)); if (share is null) { return NotFound("User permissions not found"); } await _playlistManager.RemoveUserFromShares(playlistId, callingUserId, share).ConfigureAwait(false); return NoContent(); } /// /// Adds items to a playlist. /// /// The playlist id. /// Item id, comma delimited. /// The userId. /// Items added to playlist. /// An on success. [HttpPost("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task AddItemToPlaylist( [FromRoute, Required] Guid playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); return NoContent(); } /// /// Moves a playlist item. /// /// The playlist id. /// The item id. /// The new index. /// Item moved to new index. /// An on success. [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task MoveItem( [FromRoute, Required] string playlistId, [FromRoute, Required] string itemId, [FromRoute, Required] int newIndex) { await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); return NoContent(); } /// /// Removes items from a playlist. /// /// The playlist id. /// The item ids, comma delimited. /// Items removed. /// An on success. [HttpDelete("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task RemoveItemFromPlaylist( [FromRoute, Required] string playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) { await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); return NoContent(); } /// /// Gets the original items of a playlist. /// /// The playlist id. /// User id. /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. /// Optional. Include image information in output. /// Optional. Include user data. /// Optional. The max number of images to return, per image type. /// Optional. The image types to include in the output. /// Original playlist returned. /// Playlist not found. /// The original playlist items. [HttpGet("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult> GetPlaylistItems( [FromRoute, Required] Guid playlistId, [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var playlist = (Playlist)_libraryManager.GetItemById(playlistId); if (playlist is null) { return NotFound(); } var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var items = playlist.GetManageableItems().ToArray(); var count = items.Length; if (startIndex.HasValue) { items = items.Skip(startIndex.Value).ToArray(); } if (limit.HasValue) { items = items.Take(limit.Value).ToArray(); } var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); for (int index = 0; index < dtos.Count; index++) { dtos[index].PlaylistItemId = items[index].Item1.Id; } var result = new QueryResult( startIndex, count, dtos); return result; } }