From 4b708aa9872fabf6e3ee2bd2af55df6b9d281f5c Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 12:04:28 +0200 Subject: [PATCH] backend: add endpoint to add a whole album to a playlist --- .../PlayLists/AddAlbumToPlayList.cs | 86 ++++++++++++++++++ .../Responses/AddAlbumToPlayListResponse.cs | 4 + .../Musify.Api/Endpoints/PlayListEndpoints.cs | 22 +++++ .../AddAlbumToPlayListCommandHandlerTests.cs | 91 +++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs create mode 100644 backend/Core/Musify.Application/PlayLists/Responses/AddAlbumToPlayListResponse.cs create mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs diff --git a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs new file mode 100644 index 0000000..55b39bc --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs @@ -0,0 +1,86 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.PlayLists.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.PlayLists +{ + public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId) + : ICommand>; + + public class AddAlbumToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playList == null) + { + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Error.NotFound(); + } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Error.Unauthorized(); + } + + var albumExists = await database.Albums + .AsNoTracking() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + if (!albumExists) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(description: $"Album {request.AlbumId} not found"); + } + + var albumTrackIds = await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Select(albumTrack => albumTrack.TrackId) + .ToListAsync(cancellationToken); + + var playListTracksQuery = database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId); + + var existingTrackIds = (await playListTracksQuery + .Select(plt => plt.TrackId) + .ToListAsync(cancellationToken)).ToHashSet(); + + var newTrackIds = albumTrackIds + .Where(trackId => !existingTrackIds.Contains(trackId)) + .ToList(); + + if (newTrackIds.Count == 0) + return new AddAlbumToPlayListResponse(0); + + var nextPosition = existingTrackIds.Count > 0 + ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 + : 0; + + await database.PlayListHasTracks.AddRangeAsync( + newTrackIds.Select((trackId, index) => new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = trackId, + Position = nextPosition + index + }), + cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}", + newTrackIds.Count, request.AlbumId, request.PlayListId); + + return new AddAlbumToPlayListResponse(newTrackIds.Count); + } + } +} diff --git a/backend/Core/Musify.Application/PlayLists/Responses/AddAlbumToPlayListResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/AddAlbumToPlayListResponse.cs new file mode 100644 index 0000000..2439a7d --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/Responses/AddAlbumToPlayListResponse.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.PlayLists.Responses +{ + public record AddAlbumToPlayListResponse(int AddedCount); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 8ab6dc2..7ea110b 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -94,6 +94,14 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); + group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList) + .WithName("AddAlbumToPlayList") + .WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) .WithName("RemoveTrackFromPlayList") .WithSummary("Remove A Track From A PlayList.") @@ -234,6 +242,20 @@ public static class PlayListEndpoints return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); } + private static async Task AddAlbumToPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid playlistId, + Guid albumId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId), + cancellationToken); + + return result.ToHttpResult(); + } + private static async Task RemoveTrackFromPlayList( IMediator mediator, CurrentUser currentUser, diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs new file mode 100644 index 0000000..b71d492 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddAlbumToPlayListCommandHandlerTests.cs @@ -0,0 +1,91 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.PlayLists; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class AddAlbumToPlayListCommandHandlerTests : HandlerTestBase + { + private AddAlbumToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_AddsAlbumTracksInAlbumOrder() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var album = TestEntities.Album(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + await SeedAsync( + owner, playList, album, first, second, + new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.AddedCount); + var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([0, 1], links.Select(link => link.Position)); + } + + [Fact] + public async Task Handle_SkipsTracksAlreadyInPlayList_AndAppendsAfterThem() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var album = TestEntities.Album(owner.Id); + var existing = TestEntities.Track(owner, "Existing"); + var fresh = TestEntities.Track(owner, "Fresh"); + await SeedAsync( + owner, playList, album, existing, fresh, + new AlbumHasTrack { AlbumId = album.Id, TrackId = existing.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = fresh.Id, TrackNumber = 2 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 4 }); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Value.AddedCount); + var link = await Database.PlayListHasTracks.SingleAsync(l => l.TrackId == fresh.Id, TestContext.Current.CancellationToken); + Assert.Equal(5, link.Position); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, playList, album); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +}