diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index a50435ae69..a03c1214d6 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -11,7 +9,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Resolvers; using MediaBrowser.LocalMetadata.Savers; -using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers { @@ -20,11 +17,11 @@ namespace Emby.Server.Implementations.Library.Resolvers /// public class PlaylistResolver : GenericFolderResolver { - private CollectionType?[] _musicPlaylistCollectionTypes = - { + private readonly CollectionType?[] _musicPlaylistCollectionTypes = + [ null, CollectionType.music - }; + ]; /// protected override Playlist Resolve(ItemResolveArgs args) diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index aea8d65322..7a6cf9efff 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -22,6 +22,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Playlists; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using PlaylistsNET.Content; @@ -59,6 +60,11 @@ namespace Emby.Server.Implementations.Playlists _appConfig = appConfig; } + public Playlist GetPlaylistForUser(Guid playlistId, Guid userId) + { + return GetPlaylists(userId).Where(p => p.Id.Equals(playlistId)).FirstOrDefault(); + } + public IEnumerable GetPlaylists(Guid userId) { var user = _userManager.GetUserById(userId); @@ -66,61 +72,56 @@ namespace Emby.Server.Implementations.Playlists return GetPlaylistsFolder(userId).GetChildren(user, true).OfType(); } - public async Task CreatePlaylist(PlaylistCreationRequest options) + public async Task CreatePlaylist(PlaylistCreationRequest request) { - var name = options.Name; + var name = request.Name; var folderName = _fileSystem.GetValidFilename(name); - var parentFolder = GetPlaylistsFolder(options.UserId); + var parentFolder = GetPlaylistsFolder(request.UserId); if (parentFolder is null) { throw new ArgumentException(nameof(parentFolder)); } - if (options.MediaType is null || options.MediaType == MediaType.Unknown) + if (request.MediaType is null || request.MediaType == MediaType.Unknown) { - foreach (var itemId in options.ItemIdList) + foreach (var itemId in request.ItemIdList) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - throw new ArgumentException("No item exists with the supplied Id"); - } - + var item = _libraryManager.GetItemById(itemId) ?? throw new ArgumentException("No item exists with the supplied Id"); if (item.MediaType != MediaType.Unknown) { - options.MediaType = item.MediaType; + request.MediaType = item.MediaType; } else if (item is MusicArtist || item is MusicAlbum || item is MusicGenre) { - options.MediaType = MediaType.Audio; + request.MediaType = MediaType.Audio; } else if (item is Genre) { - options.MediaType = MediaType.Video; + request.MediaType = MediaType.Video; } else { if (item is Folder folder) { - options.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist) + request.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist) .Select(i => i.MediaType) .FirstOrDefault(i => i != MediaType.Unknown); } } - if (options.MediaType is not null && options.MediaType != MediaType.Unknown) + if (request.MediaType is not null && request.MediaType != MediaType.Unknown) { break; } } } - if (options.MediaType is null || options.MediaType == MediaType.Unknown) + if (request.MediaType is null || request.MediaType == MediaType.Unknown) { - options.MediaType = MediaType.Audio; + request.MediaType = MediaType.Audio; } - var user = _userManager.GetUserById(options.UserId); + var user = _userManager.GetUserById(request.UserId); var path = Path.Combine(parentFolder.Path, folderName); path = GetTargetPath(path); @@ -133,19 +134,20 @@ namespace Emby.Server.Implementations.Playlists { Name = name, Path = path, - OwnerUserId = options.UserId, - Shares = options.Shares ?? Array.Empty() + OwnerUserId = request.UserId, + Shares = request.Users ?? [], + OpenAccess = request.Public ?? false }; - playlist.SetMediaType(options.MediaType); + playlist.SetMediaType(request.MediaType); parentFolder.AddChild(playlist); await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) .ConfigureAwait(false); - if (options.ItemIdList.Count > 0) + if (request.ItemIdList.Count > 0) { - await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false) + await AddToPlaylistInternal(playlist.Id, request.ItemIdList, user, new DtoOptions(false) { EnableImages = true }).ConfigureAwait(false); @@ -160,7 +162,7 @@ namespace Emby.Server.Implementations.Playlists } } - private string GetTargetPath(string path) + private static string GetTargetPath(string path) { while (Directory.Exists(path)) { @@ -170,14 +172,14 @@ namespace Emby.Server.Implementations.Playlists return path; } - private List GetPlaylistItems(IEnumerable itemIds, MediaType playlistMediaType, User user, DtoOptions options) + private IReadOnlyList GetPlaylistItems(IEnumerable itemIds, MediaType playlistMediaType, User user, DtoOptions options) { - var items = itemIds.Select(i => _libraryManager.GetItemById(i)).Where(i => i is not null); + var items = itemIds.Select(_libraryManager.GetItemById).Where(i => i is not null); return Playlist.GetPlaylistItems(playlistMediaType, items, user, options); } - public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection itemIds, Guid userId) + public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection itemIds, Guid userId) { var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); @@ -231,13 +233,8 @@ namespace Emby.Server.Implementations.Playlists // Update the playlist in the repository playlist.LinkedChildren = newLinkedChildren; - await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - // Update the playlist on disk - if (playlist.IsFile) - { - SavePlaylistFile(playlist); - } + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); // Refresh playlist metadata _providerManager.QueueRefresh( @@ -249,7 +246,7 @@ namespace Emby.Server.Implementations.Playlists RefreshPriority.High); } - public async Task RemoveFromPlaylistAsync(string playlistId, IEnumerable entryIds) + public async Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable entryIds) { if (_libraryManager.GetItemById(playlistId) is not Playlist playlist) { @@ -266,12 +263,7 @@ namespace Emby.Server.Implementations.Playlists .Select(i => i.Item1) .ToArray(); - await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - - if (playlist.IsFile) - { - SavePlaylistFile(playlist); - } + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); _providerManager.QueueRefresh( playlist.Id, @@ -313,14 +305,9 @@ namespace Emby.Server.Implementations.Playlists newList.Insert(newIndex, item); } - playlist.LinkedChildren = newList.ToArray(); + playlist.LinkedChildren = [.. newList]; - await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - - if (playlist.IsFile) - { - SavePlaylistFile(playlist); - } + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); } /// @@ -430,8 +417,11 @@ namespace Emby.Server.Implementations.Playlists } else if (extension.Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) { - var playlist = new M3uPlaylist(); - playlist.IsExtended = true; + var playlist = new M3uPlaylist + { + IsExtended = true + }; + foreach (var child in item.GetLinkedChildren()) { var entry = new M3uPlaylistEntry() @@ -481,7 +471,7 @@ namespace Emby.Server.Implementations.Playlists } } - private string NormalizeItemPath(string playlistPath, string itemPath) + private static string NormalizeItemPath(string playlistPath, string itemPath) { return MakeRelativePath(Path.GetDirectoryName(playlistPath), itemPath); } @@ -537,16 +527,11 @@ namespace Emby.Server.Implementations.Playlists { // Update owner if shared var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToArray(); - if (rankedShares.Length > 0 && Guid.TryParse(rankedShares[0].UserId, out var guid)) + if (rankedShares.Length > 0) { - playlist.OwnerUserId = guid; + playlist.OwnerUserId = rankedShares[0].UserId; playlist.Shares = rankedShares.Skip(1).ToArray(); - await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - - if (playlist.IsFile) - { - SavePlaylistFile(playlist); - } + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); } else if (!playlist.OpenAccess) { @@ -563,5 +548,76 @@ namespace Emby.Server.Implementations.Playlists } } } + + public async Task UpdatePlaylist(PlaylistUpdateRequest request) + { + var playlist = GetPlaylistForUser(request.Id, request.UserId); + + if (request.Ids is not null) + { + playlist.LinkedChildren = []; + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); + + var user = _userManager.GetUserById(request.UserId); + await AddToPlaylistInternal(request.Id, request.Ids, user, new DtoOptions(false) + { + EnableImages = true + }).ConfigureAwait(false); + + playlist = GetPlaylistForUser(request.Id, request.UserId); + } + + if (request.Name is not null) + { + playlist.Name = request.Name; + } + + if (request.Users is not null) + { + playlist.Shares = request.Users; + } + + if (request.Public is not null) + { + playlist.OpenAccess = request.Public.Value; + } + + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); + } + + public async Task AddUserToShares(PlaylistUserUpdateRequest request) + { + var userId = request.UserId; + var playlist = GetPlaylistForUser(request.Id, userId); + var shares = playlist.Shares.ToList(); + var existingUserShare = shares.FirstOrDefault(s => s.UserId.Equals(userId)); + if (existingUserShare is not null) + { + shares.Remove(existingUserShare); + } + + shares.Add(new PlaylistUserPermissions(userId, request.CanEdit ?? false)); + playlist.Shares = shares; + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); + } + + public async Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share) + { + var playlist = GetPlaylistForUser(playlistId, userId); + var shares = playlist.Shares.ToList(); + shares.Remove(share); + playlist.Shares = shares; + await UpdatePlaylistInternal(playlist).ConfigureAwait(false); + } + + private async Task UpdatePlaylistInternal(Playlist playlist) + { + await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + + if (playlist.IsFile) + { + SavePlaylistFile(playlist); + } + } } } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 0e7c3f1556..1100f85cf5 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -92,12 +92,232 @@ public class PlaylistsController : BaseJellyfinApiController Name = name ?? createPlaylistRequest?.Name, ItemIdList = ids, UserId = userId.Value, - MediaType = mediaType ?? createPlaylistRequest?.MediaType + MediaType = mediaType ?? createPlaylistRequest?.MediaType, + Users = createPlaylistRequest?.Users.ToArray() ?? [], + Public = createPlaylistRequest?.IsPublic }).ConfigureAwait(false); return result; } + /// + /// Updates a playlist. + /// + /// The playlist id. + /// The id. + /// Playlist updated. + /// Access forbidden. + /// Playlist not found. + /// + /// A that represents the asynchronous operation to update a playlist. + /// The task result contains an indicating success. + /// + [HttpPost("{playlistId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePlaylist( + [FromRoute, Required] Guid playlistId, + [FromBody, Required] 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 Forbid(); + } + + await _playlistManager.UpdatePlaylist(new PlaylistUpdateRequest + { + UserId = callingUserId, + Id = playlistId, + Name = updatePlaylistRequest.Name, + Ids = updatePlaylistRequest.Ids, + Users = updatePlaylistRequest.Users, + Public = updatePlaylistRequest.IsPublic + }).ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Get a playlist's users. + /// + /// The playlist id. + /// Found shares. + /// Access forbidden. + /// Playlist not found. + /// + /// A list of objects. + /// + [HttpGet("{playlistId}/Users")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [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); + + return isPermitted ? playlist.Shares.ToList() : Forbid(); + } + + /// + /// Get a playlist user. + /// + /// The playlist id. + /// The user id. + /// User permission found. + /// Access forbidden. + /// Playlist not found. + /// + /// . + /// + [HttpGet("{playlistId}/Users/{userId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetPlaylistUser( + [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 userPermission = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId)); + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)) + || userId.Equals(callingUserId); + + if (!isPermitted) + { + return Forbid(); + } + + if (userPermission is not null) + { + return userPermission; + } + + return NotFound("User permissions not found"); + } + + /// + /// Modify a user of a playlist's users. + /// + /// The playlist id. + /// The user id. + /// The . + /// User's permissions modified. + /// Access forbidden. + /// 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}/Users/{userId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePlaylistUser( + [FromRoute, Required] Guid playlistId, + [FromRoute, Required] Guid userId, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow), Required] 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); + + if (!isPermitted) + { + return Forbid(); + } + + await _playlistManager.AddUserToShares(new PlaylistUserUpdateRequest + { + Id = playlistId, + UserId = userId, + CanEdit = updatePlaylistUserRequest.CanEdit + }).ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Remove a user from a playlist's users. + /// + /// 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}/Users/{userId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [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 Forbid(); + } + + 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. /// @@ -105,16 +325,34 @@ public class PlaylistsController : BaseJellyfinApiController /// Item id, comma delimited. /// The userId. /// Items added to playlist. + /// Access forbidden. + /// Playlist not found. /// An on success. [HttpPost("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task AddToPlaylist( + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task AddItemToPlaylist( [FromRoute, Required] Guid playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); + var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(userId.Value) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(userId.Value)); + + if (!isPermitted) + { + return Forbid(); + } + + await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); return NoContent(); } @@ -125,14 +363,34 @@ public class PlaylistsController : BaseJellyfinApiController /// The item id. /// The new index. /// Item moved to new index. + /// Access forbidden. + /// Playlist not found. /// An on success. [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task MoveItem( [FromRoute, Required] string playlistId, [FromRoute, Required] string itemId, [FromRoute, Required] int newIndex) { + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(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 Forbid(); + } + await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); return NoContent(); } @@ -143,14 +401,34 @@ public class PlaylistsController : BaseJellyfinApiController /// The playlist id. /// The item ids, comma delimited. /// Items removed. + /// Access forbidden. + /// Playlist not found. /// An on success. [HttpDelete("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RemoveFromPlaylist( + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RemoveItemFromPlaylist( [FromRoute, Required] string playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(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 Forbid(); + } + + await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); return NoContent(); } @@ -167,10 +445,12 @@ public class PlaylistsController : BaseJellyfinApiController /// Optional. The max number of images to return, per image type. /// Optional. The image types to include in the output. /// Original playlist returned. + /// Access forbidden. /// Playlist not found. /// The original playlist items. [HttpGet("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult> GetPlaylistItems( [FromRoute, Required] Guid playlistId, @@ -184,10 +464,19 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); - var playlist = (Playlist)_libraryManager.GetItemById(playlistId); + var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value); if (playlist is null) { - return NotFound(); + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OpenAccess + || playlist.OwnerUserId.Equals(userId.Value) + || playlist.Shares.Any(s => s.UserId.Equals(userId.Value)); + + if (!isPermitted) + { + return Forbid(); } var user = userId.IsNullOrEmpty() diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index bdc4888719..3cbdd031a1 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Model.Entities; namespace Jellyfin.Api.Models.PlaylistDtos; @@ -14,13 +15,13 @@ public class CreatePlaylistDto /// /// Gets or sets the name of the new playlist. /// - public string? Name { get; set; } + public required string Name { get; set; } /// /// Gets or sets item ids to add to the playlist. /// [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList Ids { get; set; } = Array.Empty(); + public IReadOnlyList Ids { get; set; } = []; /// /// Gets or sets the user id. @@ -31,4 +32,14 @@ public class CreatePlaylistDto /// Gets or sets the media type. /// public MediaType? MediaType { get; set; } + + /// + /// Gets or sets the playlist users. + /// + public IReadOnlyList Users { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the playlist is public. + /// + public bool IsPublic { get; set; } = true; } diff --git a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs new file mode 100644 index 0000000000..80e20995c6 --- /dev/null +++ b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.Api.Models.PlaylistDtos; + +/// +/// Update existing playlist dto. Fields set to `null` will not be updated and keep their current values. +/// +public class UpdatePlaylistDto +{ + /// + /// Gets or sets the name of the new playlist. + /// + public string? Name { get; set; } + + /// + /// Gets or sets item ids of the playlist. + /// + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList? Ids { get; set; } + + /// + /// Gets or sets the playlist users. + /// + public IReadOnlyList? Users { get; set; } + + /// + /// Gets or sets a value indicating whether the playlist is public. + /// + public bool? IsPublic { get; set; } +} diff --git a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs new file mode 100644 index 0000000000..60467b5e70 --- /dev/null +++ b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Api.Models.PlaylistDtos; + +/// +/// Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values. +/// +public class UpdatePlaylistUserDto +{ + /// + /// Gets or sets a value indicating whether the user can edit the playlist. + /// + public bool? CanEdit { get; set; } +} diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs index cf31820034..3655a610d3 100644 --- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs +++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs @@ -54,12 +54,12 @@ internal class FixPlaylistOwner : IMigrationRoutine foreach (var playlist in playlists) { var shares = playlist.Shares; - if (shares.Length > 0) + if (shares.Count > 0) { var firstEditShare = shares.First(x => x.CanEdit); - if (firstEditShare is not null && Guid.TryParse(firstEditShare.UserId, out var guid)) + if (firstEditShare is not null) { - playlist.OwnerUserId = guid; + playlist.OwnerUserId = firstEditShare.UserId; playlist.Shares = shares.Where(x => x != firstEditShare).ToArray(); playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); _playlistManager.SavePlaylistFile(playlist); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index ac9698ec9f..5f9840b1bb 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -833,7 +833,7 @@ namespace MediaBrowser.Controller.Entities return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders); } - public bool CanDelete(User user) + public virtual bool CanDelete(User user) { var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType().ToList(); diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index bb68a3b6dd..cbe4bd87f5 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -4,12 +4,35 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Playlists; namespace MediaBrowser.Controller.Playlists { public interface IPlaylistManager { + /// + /// Gets the playlist. + /// + /// The playlist identifier. + /// The user identifier. + /// Playlist. + Playlist GetPlaylistForUser(Guid playlistId, Guid userId); + + /// + /// Creates the playlist. + /// + /// The . + /// The created playlist. + Task CreatePlaylist(PlaylistCreationRequest request); + + /// + /// Updates a playlist. + /// + /// The . + /// Task. + Task UpdatePlaylist(PlaylistUpdateRequest request); + /// /// Gets the playlists. /// @@ -18,11 +41,20 @@ namespace MediaBrowser.Controller.Playlists IEnumerable GetPlaylists(Guid userId); /// - /// Creates the playlist. + /// Adds a share to the playlist. + /// + /// The . + /// Task. + Task AddUserToShares(PlaylistUserUpdateRequest request); + + /// + /// Removes a share from the playlist. /// - /// The options. - /// Task<Playlist>. - Task CreatePlaylist(PlaylistCreationRequest options); + /// The playlist identifier. + /// The user identifier. + /// The share. + /// Task. + Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share); /// /// Adds to playlist. @@ -31,7 +63,7 @@ namespace MediaBrowser.Controller.Playlists /// The item ids. /// The user identifier. /// Task. - Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection itemIds, Guid userId); + Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection itemIds, Guid userId); /// /// Removes from playlist. @@ -39,7 +71,7 @@ namespace MediaBrowser.Controller.Playlists /// The playlist identifier. /// The entry ids. /// Task. - Task RemoveFromPlaylistAsync(string playlistId, IEnumerable entryIds); + Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable entryIds); /// /// Gets the playlists folder. diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index ca032e7f6e..34b34e5780 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -16,24 +16,23 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Playlists { public class Playlist : Folder, IHasShares { - public static readonly IReadOnlyList SupportedExtensions = new[] - { + public static readonly IReadOnlyList SupportedExtensions = + [ ".m3u", ".m3u8", ".pls", ".wpl", ".zpl" - }; + ]; public Playlist() { - Shares = Array.Empty(); + Shares = []; OpenAccess = false; } @@ -41,7 +40,7 @@ namespace MediaBrowser.Controller.Playlists public bool OpenAccess { get; set; } - public Share[] Shares { get; set; } + public IReadOnlyList Shares { get; set; } [JsonIgnore] public bool IsFile => IsPlaylistFile(Path); @@ -130,7 +129,7 @@ namespace MediaBrowser.Controller.Playlists protected override List LoadChildren() { // Save a trip to the database - return new List(); + return []; } protected override Task ValidateChildrenInternal(IProgress progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) @@ -145,7 +144,7 @@ namespace MediaBrowser.Controller.Playlists protected override IEnumerable GetNonCachedChildren(IDirectoryService directoryService) { - return new List(); + return []; } public override IEnumerable GetRecursiveChildren(User user, InternalItemsQuery query) @@ -167,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists return base.GetChildren(user, true, query); } - public static List GetPlaylistItems(MediaType playlistMediaType, IEnumerable inputItems, User user, DtoOptions options) + public static IReadOnlyList GetPlaylistItems(MediaType playlistMediaType, IEnumerable inputItems, User user, DtoOptions options) { if (user is not null) { @@ -192,9 +191,9 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { BaseItemKind.Audio }, - GenreIds = new[] { musicGenre.Id }, - OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, + IncludeItemTypes = [BaseItemKind.Audio], + GenreIds = [musicGenre.Id], + OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)], DtoOptions = options }); } @@ -204,9 +203,9 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { BaseItemKind.Audio }, - ArtistIds = new[] { musicArtist.Id }, - OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, + IncludeItemTypes = [BaseItemKind.Audio], + ArtistIds = [musicArtist.Id], + OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)], DtoOptions = options }); } @@ -217,8 +216,8 @@ namespace MediaBrowser.Controller.Playlists { Recursive = true, IsFolder = false, - OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, - MediaTypes = new[] { mediaType }, + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)], + MediaTypes = [mediaType], EnableTotalRecordCount = false, DtoOptions = options }; @@ -226,7 +225,7 @@ namespace MediaBrowser.Controller.Playlists return folder.GetItemList(query); } - return new[] { item }; + return [item]; } public override bool IsVisible(User user) @@ -248,12 +247,17 @@ namespace MediaBrowser.Controller.Playlists } var shares = Shares; - if (shares.Length == 0) + if (shares.Count == 0) { return false; } - return shares.Any(share => Guid.TryParse(share.UserId, out var id) && id.Equals(userId)); + return shares.Any(s => s.UserId.Equals(userId)); + } + + public override bool CanDelete(User user) + { + return user.HasPermission(PermissionKind.IsAdministrator) || user.Id.Equals(OwnerUserId); } public override bool IsVisibleStandalone(User user) diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index 8a870e0d9b..a7e027d94a 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -519,7 +519,7 @@ namespace MediaBrowser.LocalMetadata.Parsers private void FetchFromSharesNode(XmlReader reader, IHasShares item) { - var list = new List(); + var list = new List(); reader.MoveToContent(); reader.Read(); @@ -565,7 +565,7 @@ namespace MediaBrowser.LocalMetadata.Parsers } } - item.Shares = list.ToArray(); + item.Shares = [.. list]; } /// @@ -830,12 +830,12 @@ namespace MediaBrowser.LocalMetadata.Parsers /// /// The xml reader. /// The share. - protected Share? GetShare(XmlReader reader) + protected PlaylistUserPermissions? GetShare(XmlReader reader) { - var item = new Share(); - reader.MoveToContent(); reader.Read(); + string? userId = null; + var canEdit = false; // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) @@ -845,10 +845,10 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "UserId": - item.UserId = reader.ReadNormalizedString(); + userId = reader.ReadNormalizedString(); break; case "CanEdit": - item.CanEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase); + canEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase); break; default: reader.Skip(); @@ -862,9 +862,9 @@ namespace MediaBrowser.LocalMetadata.Parsers } // This is valid - if (!string.IsNullOrWhiteSpace(item.UserId)) + if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid)) { - return item; + return new PlaylistUserPermissions(guid, canEdit); } return null; diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index 5a71930799..ee0d10bea9 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -420,19 +420,16 @@ namespace MediaBrowser.LocalMetadata.Savers foreach (var share in item.Shares) { - if (share.UserId is not null) - { - await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false); + await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false); - await writer.WriteElementStringAsync(null, "UserId", null, share.UserId).ConfigureAwait(false); - await writer.WriteElementStringAsync( - null, - "CanEdit", - null, - share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false); + await writer.WriteElementStringAsync(null, "UserId", null, share.UserId.ToString()).ConfigureAwait(false); + await writer.WriteElementStringAsync( + null, + "CanEdit", + null, + share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false); - await writer.WriteEndElementAsync().ConfigureAwait(false); - } + await writer.WriteEndElementAsync().ConfigureAwait(false); } await writer.WriteEndElementAsync().ConfigureAwait(false); diff --git a/MediaBrowser.Model/Entities/IHasShares.cs b/MediaBrowser.Model/Entities/IHasShares.cs index b34d1a0376..8c4ba6c425 100644 --- a/MediaBrowser.Model/Entities/IHasShares.cs +++ b/MediaBrowser.Model/Entities/IHasShares.cs @@ -1,4 +1,6 @@ -namespace MediaBrowser.Model.Entities; +using System.Collections.Generic; + +namespace MediaBrowser.Model.Entities; /// /// Interface for access to shares. @@ -8,5 +10,5 @@ public interface IHasShares /// /// Gets or sets the shares. /// - Share[] Shares { get; set; } + IReadOnlyList Shares { get; set; } } diff --git a/MediaBrowser.Model/Entities/PlaylistUserPermissions.cs b/MediaBrowser.Model/Entities/PlaylistUserPermissions.cs new file mode 100644 index 0000000000..b5f017d2bf --- /dev/null +++ b/MediaBrowser.Model/Entities/PlaylistUserPermissions.cs @@ -0,0 +1,30 @@ +using System; + +namespace MediaBrowser.Model.Entities; + +/// +/// Class to hold data on user permissions for playlists. +/// +public class PlaylistUserPermissions +{ + /// + /// Initializes a new instance of the class. + /// + /// The user id. + /// Edit permission. + public PlaylistUserPermissions(Guid userId, bool canEdit = false) + { + UserId = userId; + CanEdit = canEdit; + } + + /// + /// Gets or sets the user id. + /// + public Guid UserId { get; set; } + + /// + /// Gets or sets a value indicating whether the user has edit permissions. + /// + public bool CanEdit { get; set; } +} diff --git a/MediaBrowser.Model/Entities/Share.cs b/MediaBrowser.Model/Entities/Share.cs deleted file mode 100644 index 186aad1892..0000000000 --- a/MediaBrowser.Model/Entities/Share.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace MediaBrowser.Model.Entities; - -/// -/// Class to hold data on sharing permissions. -/// -public class Share -{ - /// - /// Gets or sets the user id. - /// - public string? UserId { get; set; } - - /// - /// Gets or sets a value indicating whether the user has edit permissions. - /// - public bool CanEdit { get; set; } -} diff --git a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs index 62d496d047..ec54b1afd3 100644 --- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs +++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs @@ -18,7 +18,7 @@ public class PlaylistCreationRequest /// /// Gets or sets the list of items. /// - public IReadOnlyList ItemIdList { get; set; } = Array.Empty(); + public IReadOnlyList ItemIdList { get; set; } = []; /// /// Gets or sets the media type. @@ -31,7 +31,12 @@ public class PlaylistCreationRequest public Guid UserId { get; set; } /// - /// Gets or sets the shares. + /// Gets or sets the user permissions. /// - public Share[]? Shares { get; set; } + public IReadOnlyList Users { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the playlist is public. + /// + public bool? Public { get; set; } = true; } diff --git a/MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs b/MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs new file mode 100644 index 0000000000..db290bbdbf --- /dev/null +++ b/MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Model.Playlists; + +/// +/// A playlist update request. +/// +public class PlaylistUpdateRequest +{ + /// + /// Gets or sets the id of the playlist. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the id of the user updating the playlist. + /// + public Guid UserId { get; set; } + + /// + /// Gets or sets the name of the playlist. + /// + public string? Name { get; set; } + + /// + /// Gets or sets item ids to add to the playlist. + /// + public IReadOnlyList? Ids { get; set; } + + /// + /// Gets or sets the playlist users. + /// + public IReadOnlyList? Users { get; set; } + + /// + /// Gets or sets a value indicating whether the playlist is public. + /// + public bool? Public { get; set; } +} diff --git a/MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs b/MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs new file mode 100644 index 0000000000..1840efdf37 --- /dev/null +++ b/MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs @@ -0,0 +1,24 @@ +using System; + +namespace MediaBrowser.Model.Playlists; + +/// +/// A playlist user update request. +/// +public class PlaylistUserUpdateRequest +{ + /// + /// Gets or sets the id of the playlist. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the id of the updated user. + /// + public Guid UserId { get; set; } + + /// + /// Gets or sets a value indicating whether the user can edit the playlist. + /// + public bool? CanEdit { get; set; } +}