Quellcode durchsuchen

Merge pull request #11220 from Shadowghost/add-playlist-acl-api

Add playlist ACL endpoints
Joshua M. Boniface vor 1 Jahr
Ursprung
Commit
ee1d6332ee

+ 3 - 6
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
     /// </summary>
     public class PlaylistResolver : GenericFolderResolver<Playlist>
     {
-        private CollectionType?[] _musicPlaylistCollectionTypes =
-        {
+        private readonly CollectionType?[] _musicPlaylistCollectionTypes =
+        [
             null,
             CollectionType.music
-        };
+        ];
 
         /// <inheritdoc/>
         protected override Playlist Resolve(ItemResolveArgs args)

+ 115 - 59
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<Playlist> GetPlaylists(Guid userId)
         {
             var user = _userManager.GetUserById(userId);
@@ -66,61 +72,56 @@ namespace Emby.Server.Implementations.Playlists
             return GetPlaylistsFolder(userId).GetChildren(user, true).OfType<Playlist>();
         }
 
-        public async Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options)
+        public async Task<PlaylistCreationResult> 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<Share>()
+                    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<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, MediaType playlistMediaType, User user, DtoOptions options)
+        private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> 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<Guid> itemIds, Guid userId)
+        public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> 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<string> entryIds)
+        public async Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable<string> 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);
         }
 
         /// <inheritdoc />
@@ -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);
+            }
+        }
     }
 }

+ 296 - 7
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;
     }
 
+    /// <summary>
+    /// Updates a playlist.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="updatePlaylistRequest">The <see cref="UpdatePlaylistDto"/> id.</param>
+    /// <response code="204">Playlist updated.</response>
+    /// <response code="403">Access forbidden.</response>
+    /// <response code="404">Playlist not found.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to update a playlist.
+    /// The task result contains an <see cref="OkResult"/> indicating success.
+    /// </returns>
+    [HttpPost("{playlistId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> 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();
+    }
+
+    /// <summary>
+    /// Get a playlist's users.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <response code="200">Found shares.</response>
+    /// <response code="403">Access forbidden.</response>
+    /// <response code="404">Playlist not found.</response>
+    /// <returns>
+    /// A list of <see cref="PlaylistUserPermissions"/> objects.
+    /// </returns>
+    [HttpGet("{playlistId}/Users")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<IReadOnlyList<PlaylistUserPermissions>> 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();
+    }
+
+    /// <summary>
+    /// Get a playlist user.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <response code="200">User permission found.</response>
+    /// <response code="403">Access forbidden.</response>
+    /// <response code="404">Playlist not found.</response>
+    /// <returns>
+    /// <see cref="PlaylistUserPermissions"/>.
+    /// </returns>
+    [HttpGet("{playlistId}/Users/{userId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<PlaylistUserPermissions?> 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");
+    }
+
+    /// <summary>
+    /// Modify a user of a playlist's users.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <param name="updatePlaylistUserRequest">The <see cref="UpdatePlaylistUserDto"/>.</param>
+    /// <response code="204">User's permissions modified.</response>
+    /// <response code="403">Access forbidden.</response>
+    /// <response code="404">Playlist not found.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to modify an user's playlist permissions.
+    /// The task result contains an <see cref="OkResult"/> indicating success.
+    /// </returns>
+    [HttpPost("{playlistId}/Users/{userId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> 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();
+    }
+
+    /// <summary>
+    /// Remove a user from a playlist's users.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <param name="userId">The user id.</param>
+    /// <response code="204">User permissions removed from playlist.</response>
+    /// <response code="401">Unauthorized access.</response>
+    /// <response code="404">No playlist or user permissions found.</response>
+    /// <returns>
+    /// A <see cref="Task" /> that represents the asynchronous operation to delete a user from a playlist's shares.
+    /// The task result contains an <see cref="OkResult"/> indicating success.
+    /// </returns>
+    [HttpDelete("{playlistId}/Users/{userId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> 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();
+    }
+
     /// <summary>
     /// Adds items to a playlist.
     /// </summary>
@@ -105,16 +325,34 @@ public class PlaylistsController : BaseJellyfinApiController
     /// <param name="ids">Item id, comma delimited.</param>
     /// <param name="userId">The userId.</param>
     /// <response code="204">Items added to playlist.</response>
+    /// <response code="403">Access forbidden.</response>
+    /// <response code="404">Playlist not found.</response>
     /// <returns>An <see cref="NoContentResult"/> on success.</returns>
     [HttpPost("{playlistId}/Items")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
-    public async Task<ActionResult> AddToPlaylist(
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> 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
     /// <param name="itemId">The item id.</param>
     /// <param name="newIndex">The new index.</param>
     /// <response code="204">Item moved to new index.</response>
+    /// <response code="403">Access forbidden.</response>
+    /// <response code="404">Playlist not found.</response>
     /// <returns>An <see cref="NoContentResult"/> on success.</returns>
     [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
     public async Task<ActionResult> 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
     /// <param name="playlistId">The playlist id.</param>
     /// <param name="entryIds">The item ids, comma delimited.</param>
     /// <response code="204">Items removed.</response>
+    /// <response code="403">Access forbidden.</response>
+    /// <response code="404">Playlist not found.</response>
     /// <returns>An <see cref="NoContentResult"/> on success.</returns>
     [HttpDelete("{playlistId}/Items")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
-    public async Task<ActionResult> RemoveFromPlaylist(
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> 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
     /// <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">Original playlist returned.</response>
+    /// <response code="404">Access forbidden.</response>
     /// <response code="404">Playlist not found.</response>
     /// <returns>The original playlist items.</returns>
     [HttpGet("{playlistId}/Items")]
     [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public ActionResult<QueryResult<BaseItemDto>> 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()

+ 13 - 2
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
     /// <summary>
     /// Gets or sets the name of the new playlist.
     /// </summary>
-    public string? Name { get; set; }
+    public required string Name { get; set; }
 
     /// <summary>
     /// Gets or sets item ids to add to the playlist.
     /// </summary>
     [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
-    public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>();
+    public IReadOnlyList<Guid> Ids { get; set; } = [];
 
     /// <summary>
     /// Gets or sets the user id.
@@ -31,4 +32,14 @@ public class CreatePlaylistDto
     /// Gets or sets the media type.
     /// </summary>
     public MediaType? MediaType { get; set; }
+
+    /// <summary>
+    /// Gets or sets the playlist users.
+    /// </summary>
+    public IReadOnlyList<PlaylistUserPermissions> Users { get; set; } = [];
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the playlist is public.
+    /// </summary>
+    public bool IsPublic { get; set; } = true;
 }

+ 34 - 0
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;
+
+/// <summary>
+/// Update existing playlist dto. Fields set to `null` will not be updated and keep their current values.
+/// </summary>
+public class UpdatePlaylistDto
+{
+    /// <summary>
+    /// Gets or sets the name of the new playlist.
+    /// </summary>
+    public string? Name { get; set; }
+
+    /// <summary>
+    /// Gets or sets item ids of the playlist.
+    /// </summary>
+    [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+    public IReadOnlyList<Guid>? Ids { get; set; }
+
+    /// <summary>
+    /// Gets or sets the playlist users.
+    /// </summary>
+    public IReadOnlyList<PlaylistUserPermissions>? Users { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the playlist is public.
+    /// </summary>
+    public bool? IsPublic { get; set; }
+}

+ 12 - 0
Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs

@@ -0,0 +1,12 @@
+namespace Jellyfin.Api.Models.PlaylistDtos;
+
+/// <summary>
+/// Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values.
+/// </summary>
+public class UpdatePlaylistUserDto
+{
+    /// <summary>
+    /// Gets or sets a value indicating whether the user can edit the playlist.
+    /// </summary>
+    public bool? CanEdit { get; set; }
+}

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

+ 1 - 1
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<Folder>().ToList();
 

+ 38 - 6
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
     {
+        /// <summary>
+        /// Gets the playlist.
+        /// </summary>
+        /// <param name="playlistId">The playlist identifier.</param>
+        /// <param name="userId">The user identifier.</param>
+        /// <returns>Playlist.</returns>
+        Playlist GetPlaylistForUser(Guid playlistId, Guid userId);
+
+        /// <summary>
+        /// Creates the playlist.
+        /// </summary>
+        /// <param name="request">The <see cref="PlaylistCreationRequest"/>.</param>
+        /// <returns>The created playlist.</returns>
+        Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest request);
+
+        /// <summary>
+        /// Updates a playlist.
+        /// </summary>
+        /// <param name="request">The <see cref="PlaylistUpdateRequest"/>.</param>
+        /// <returns>Task.</returns>
+        Task UpdatePlaylist(PlaylistUpdateRequest request);
+
         /// <summary>
         /// Gets the playlists.
         /// </summary>
@@ -18,11 +41,20 @@ namespace MediaBrowser.Controller.Playlists
         IEnumerable<Playlist> GetPlaylists(Guid userId);
 
         /// <summary>
-        /// Creates the playlist.
+        /// Adds a share to the playlist.
+        /// </summary>
+        /// <param name="request">The <see cref="PlaylistUserUpdateRequest"/>.</param>
+        /// <returns>Task.</returns>
+        Task AddUserToShares(PlaylistUserUpdateRequest request);
+
+        /// <summary>
+        /// Removes a share from the playlist.
         /// </summary>
-        /// <param name="options">The options.</param>
-        /// <returns>Task&lt;Playlist&gt;.</returns>
-        Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options);
+        /// <param name="playlistId">The playlist identifier.</param>
+        /// <param name="userId">The user identifier.</param>
+        /// <param name="share">The share.</param>
+        /// <returns>Task.</returns>
+        Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share);
 
         /// <summary>
         /// Adds to playlist.
@@ -31,7 +63,7 @@ namespace MediaBrowser.Controller.Playlists
         /// <param name="itemIds">The item ids.</param>
         /// <param name="userId">The user identifier.</param>
         /// <returns>Task.</returns>
-        Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
+        Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
 
         /// <summary>
         /// Removes from playlist.
@@ -39,7 +71,7 @@ namespace MediaBrowser.Controller.Playlists
         /// <param name="playlistId">The playlist identifier.</param>
         /// <param name="entryIds">The entry ids.</param>
         /// <returns>Task.</returns>
-        Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds);
+        Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds);
 
         /// <summary>
         /// Gets the playlists folder.

+ 24 - 20
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<string> SupportedExtensions = new[]
-        {
+        public static readonly IReadOnlyList<string> SupportedExtensions =
+        [
             ".m3u",
             ".m3u8",
             ".pls",
             ".wpl",
             ".zpl"
-        };
+        ];
 
         public Playlist()
         {
-            Shares = Array.Empty<Share>();
+            Shares = [];
             OpenAccess = false;
         }
 
@@ -41,7 +40,7 @@ namespace MediaBrowser.Controller.Playlists
 
         public bool OpenAccess { get; set; }
 
-        public Share[] Shares { get; set; }
+        public IReadOnlyList<PlaylistUserPermissions> Shares { get; set; }
 
         [JsonIgnore]
         public bool IsFile => IsPlaylistFile(Path);
@@ -130,7 +129,7 @@ namespace MediaBrowser.Controller.Playlists
         protected override List<BaseItem> LoadChildren()
         {
             // Save a trip to the database
-            return new List<BaseItem>();
+            return [];
         }
 
         protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
@@ -145,7 +144,7 @@ namespace MediaBrowser.Controller.Playlists
 
         protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService)
         {
-            return new List<BaseItem>();
+            return [];
         }
 
         public override IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query)
@@ -167,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists
             return base.GetChildren(user, true, query);
         }
 
-        public static List<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
+        public static IReadOnlyList<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> 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)

+ 9 - 9
MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs

@@ -519,7 +519,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
 
         private void FetchFromSharesNode(XmlReader reader, IHasShares item)
         {
-            var list = new List<Share>();
+            var list = new List<PlaylistUserPermissions>();
 
             reader.MoveToContent();
             reader.Read();
@@ -565,7 +565,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
                 }
             }
 
-            item.Shares = list.ToArray();
+            item.Shares = [.. list];
         }
 
         /// <summary>
@@ -830,12 +830,12 @@ namespace MediaBrowser.LocalMetadata.Parsers
         /// </summary>
         /// <param name="reader">The xml reader.</param>
         /// <returns>The share.</returns>
-        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;

+ 8 - 11
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);

+ 4 - 2
MediaBrowser.Model/Entities/IHasShares.cs

@@ -1,4 +1,6 @@
-namespace MediaBrowser.Model.Entities;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.Entities;
 
 /// <summary>
 /// Interface for access to shares.
@@ -8,5 +10,5 @@ public interface IHasShares
     /// <summary>
     /// Gets or sets the shares.
     /// </summary>
-    Share[] Shares { get; set; }
+    IReadOnlyList<PlaylistUserPermissions> Shares { get; set; }
 }

+ 30 - 0
MediaBrowser.Model/Entities/PlaylistUserPermissions.cs

@@ -0,0 +1,30 @@
+using System;
+
+namespace MediaBrowser.Model.Entities;
+
+/// <summary>
+/// Class to hold data on user permissions for playlists.
+/// </summary>
+public class PlaylistUserPermissions
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PlaylistUserPermissions"/> class.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="canEdit">Edit permission.</param>
+    public PlaylistUserPermissions(Guid userId, bool canEdit = false)
+    {
+        UserId = userId;
+        CanEdit = canEdit;
+    }
+
+    /// <summary>
+    /// Gets or sets the user id.
+    /// </summary>
+    public Guid UserId { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the user has edit permissions.
+    /// </summary>
+    public bool CanEdit { get; set; }
+}

+ 0 - 17
MediaBrowser.Model/Entities/Share.cs

@@ -1,17 +0,0 @@
-namespace MediaBrowser.Model.Entities;
-
-/// <summary>
-/// Class to hold data on sharing permissions.
-/// </summary>
-public class Share
-{
-    /// <summary>
-    /// Gets or sets the user id.
-    /// </summary>
-    public string? UserId { get; set; }
-
-    /// <summary>
-    /// Gets or sets a value indicating whether the user has edit permissions.
-    /// </summary>
-    public bool CanEdit { get; set; }
-}

+ 8 - 3
MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs

@@ -18,7 +18,7 @@ public class PlaylistCreationRequest
     /// <summary>
     /// Gets or sets the list of items.
     /// </summary>
-    public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>();
+    public IReadOnlyList<Guid> ItemIdList { get; set; } = [];
 
     /// <summary>
     /// Gets or sets the media type.
@@ -31,7 +31,12 @@ public class PlaylistCreationRequest
     public Guid UserId { get; set; }
 
     /// <summary>
-    /// Gets or sets the shares.
+    /// Gets or sets the user permissions.
     /// </summary>
-    public Share[]? Shares { get; set; }
+    public IReadOnlyList<PlaylistUserPermissions> Users { get; set; } = [];
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the playlist is public.
+    /// </summary>
+    public bool? Public { get; set; } = true;
 }

+ 41 - 0
MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs

@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Model.Playlists;
+
+/// <summary>
+/// A playlist update request.
+/// </summary>
+public class PlaylistUpdateRequest
+{
+    /// <summary>
+    /// Gets or sets the id of the playlist.
+    /// </summary>
+    public Guid Id { get; set; }
+
+    /// <summary>
+    /// Gets or sets the id of the user updating the playlist.
+    /// </summary>
+    public Guid UserId { get; set; }
+
+    /// <summary>
+    /// Gets or sets the name of the playlist.
+    /// </summary>
+    public string? Name { get; set; }
+
+    /// <summary>
+    /// Gets or sets item ids to add to the playlist.
+    /// </summary>
+    public IReadOnlyList<Guid>? Ids { get; set; }
+
+    /// <summary>
+    /// Gets or sets the playlist users.
+    /// </summary>
+    public IReadOnlyList<PlaylistUserPermissions>? Users { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the playlist is public.
+    /// </summary>
+    public bool? Public { get; set; }
+}

+ 24 - 0
MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs

@@ -0,0 +1,24 @@
+using System;
+
+namespace MediaBrowser.Model.Playlists;
+
+/// <summary>
+/// A playlist user update request.
+/// </summary>
+public class PlaylistUserUpdateRequest
+{
+    /// <summary>
+    /// Gets or sets the id of the playlist.
+    /// </summary>
+    public Guid Id { get; set; }
+
+    /// <summary>
+    /// Gets or sets the id of the updated user.
+    /// </summary>
+    public Guid UserId { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the user can edit the playlist.
+    /// </summary>
+    public bool? CanEdit { get; set; }
+}