diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index aea8d65322..6724d54d1a 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -59,6 +59,11 @@ namespace Emby.Server.Implementations.Playlists _appConfig = appConfig; } + public Playlist GetPlaylist(Guid userId, Guid playlistId) + { + return GetPlaylists(userId).Where(p => p.Id.Equals(playlistId)).FirstOrDefault(); + } + public IEnumerable GetPlaylists(Guid userId) { var user = _userManager.GetUserById(userId); @@ -160,7 +165,7 @@ namespace Emby.Server.Implementations.Playlists } } - private string GetTargetPath(string path) + private static string GetTargetPath(string path) { while (Directory.Exists(path)) { @@ -231,13 +236,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 UpdatePlaylist(playlist).ConfigureAwait(false); // Refresh playlist metadata _providerManager.QueueRefresh( @@ -266,12 +266,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 UpdatePlaylist(playlist).ConfigureAwait(false); _providerManager.QueueRefresh( playlist.Id, @@ -313,14 +308,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 UpdatePlaylist(playlist).ConfigureAwait(false); } /// @@ -430,8 +420,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 +474,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); } @@ -541,12 +534,7 @@ namespace Emby.Server.Implementations.Playlists { playlist.OwnerUserId = guid; playlist.Shares = rankedShares.Skip(1).ToArray(); - await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - - if (playlist.IsFile) - { - SavePlaylistFile(playlist); - } + await UpdatePlaylist(playlist).ConfigureAwait(false); } else if (!playlist.OpenAccess) { @@ -563,5 +551,47 @@ namespace Emby.Server.Implementations.Playlists } } } + + public async Task ToggleOpenAccess(Guid playlistId, Guid userId) + { + var playlist = GetPlaylist(userId, playlistId); + playlist.OpenAccess = !playlist.OpenAccess; + + await UpdatePlaylist(playlist).ConfigureAwait(false); + } + + public async Task AddToShares(Guid playlistId, Guid userId, Share share) + { + var playlist = GetPlaylist(userId, playlistId); + var shares = playlist.Shares.ToList(); + var existingUserShare = shares.FirstOrDefault(s => s.UserId?.Equals(share.UserId, StringComparison.OrdinalIgnoreCase) ?? false); + if (existingUserShare is not null) + { + shares.Remove(existingUserShare); + } + + shares.Add(share); + playlist.Shares = shares; + await UpdatePlaylist(playlist).ConfigureAwait(false); + } + + public async Task RemoveFromShares(Guid playlistId, Guid userId, Share share) + { + var playlist = GetPlaylist(userId, playlistId); + var shares = playlist.Shares.ToList(); + shares.Remove(share); + playlist.Shares = shares; + await UpdatePlaylist(playlist).ConfigureAwait(false); + } + + private async Task UpdatePlaylist(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..f0e8227fda 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -98,6 +98,128 @@ public class PlaylistsController : BaseJellyfinApiController return result; } + /// + /// Get a playlist's shares. + /// + /// The playlist id. + /// + /// A list of objects. + /// + [HttpGet("{playlistId}/Shares")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IReadOnlyList GetPlaylistShares( + [FromRoute, Required] Guid playlistId) + { + var userId = RequestHelpers.GetUserId(User, default); + + var playlist = _playlistManager.GetPlaylist(userId, playlistId); + var isPermitted = playlist.OwnerUserId.Equals(userId) + || playlist.Shares.Any(s => s.CanEdit && (s.UserId?.Equals(userId) ?? false)); + + return isPermitted ? playlist.Shares : new List(); + } + + /// + /// Toggles OpenAccess of a playlist. + /// + /// The playlist id. + /// + /// A that represents the asynchronous operation to toggle OpenAccess of a playlist. + /// The task result contains an indicating success. + /// + [HttpPost("{playlistId}/ToggleOpenAccess")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task ToggleopenAccess( + [FromRoute, Required] Guid playlistId) + { + var callingUserId = RequestHelpers.GetUserId(User, default); + + var playlist = _playlistManager.GetPlaylist(callingUserId, playlistId); + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && (s.UserId?.Equals(callingUserId) ?? false)); + + if (!isPermitted) + { + return Unauthorized("Unauthorized access"); + } + + await _playlistManager.ToggleOpenAccess(playlistId, callingUserId).ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Adds shares to a playlist's shares. + /// + /// The playlist id. + /// The shares. + /// + /// A that represents the asynchronous operation to add shares to a playlist. + /// The task result contains an indicating success. + /// + [HttpPost("{playlistId}/Shares")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task AddUserToPlaylistShares( + [FromRoute, Required] Guid playlistId, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Disallow)] Share[] shares) + { + var callingUserId = RequestHelpers.GetUserId(User, default); + + var playlist = _playlistManager.GetPlaylist(callingUserId, playlistId); + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && (s.UserId?.Equals(callingUserId) ?? false)); + + if (!isPermitted) + { + return Unauthorized("Unauthorized access"); + } + + foreach (var share in shares) + { + await _playlistManager.AddToShares(playlistId, callingUserId, share).ConfigureAwait(false); + } + + return NoContent(); + } + + /// + /// Remove a user from a playlist's shares. + /// + /// The playlist id. + /// The user id. + /// + /// A that represents the asynchronous operation to delete a user from a playlist's shares. + /// The task result contains an indicating success. + /// + [HttpDelete("{playlistId}/Shares")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RemoveUserFromPlaylistShares( + [FromRoute, Required] Guid playlistId, + [FromBody] Guid userId) + { + var callingUserId = RequestHelpers.GetUserId(User, default); + + var playlist = _playlistManager.GetPlaylist(callingUserId, playlistId); + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && (s.UserId?.Equals(callingUserId) ?? false)); + + if (!isPermitted) + { + return Unauthorized("Unauthorized access"); + } + + var share = playlist.Shares.FirstOrDefault(s => s.UserId?.Equals(userId) ?? false); + + if (share is null) + { + return NotFound(); + } + + await _playlistManager.RemoveFromShares(playlistId, callingUserId, share).ConfigureAwait(false); + + return NoContent(); + } + /// /// Adds items to a playlist. /// diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs index cf31820034..06596c171f 100644 --- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs +++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs @@ -54,7 +54,7 @@ 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)) diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index bb68a3b6dd..aaca1cc492 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -4,12 +4,21 @@ 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 user identifier. + /// The playlist identifier. + /// Playlist. + Playlist GetPlaylist(Guid userId, Guid playlistId); + /// /// Gets the playlists. /// @@ -17,6 +26,32 @@ namespace MediaBrowser.Controller.Playlists /// IEnumerable<Playlist>. IEnumerable GetPlaylists(Guid userId); + /// + /// Toggle OpenAccess policy of the playlist. + /// + /// The playlist identifier. + /// The user identifier. + /// Task. + Task ToggleOpenAccess(Guid playlistId, Guid userId); + + /// + /// Adds a share to the playlist. + /// + /// The playlist identifier. + /// The user identifier. + /// The share. + /// Task. + Task AddToShares(Guid playlistId, Guid userId, Share share); + + /// + /// Rremoves a share from the playlist. + /// + /// The playlist identifier. + /// The user identifier. + /// The share. + /// Task. + Task RemoveFromShares(Guid playlistId, Guid userId, Share share); + /// /// Creates the playlist. /// diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index ca032e7f6e..9a08a4ce3e 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -16,20 +16,19 @@ 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() { @@ -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); @@ -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,7 +247,7 @@ namespace MediaBrowser.Controller.Playlists } var shares = Shares; - if (shares.Length == 0) + if (shares.Count == 0) { return false; } diff --git a/MediaBrowser.Model/Entities/IHasShares.cs b/MediaBrowser.Model/Entities/IHasShares.cs index b34d1a0376..31574a3ffa 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; } }