From 69468c9dbdefdd7009afbfbde8de608e8c0482cf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 20:10:45 +0200 Subject: [PATCH] feat(backend): add the album use cases Create, update, delete and list albums, plus adding and removing their tracks, mirroring the playlist handlers. Only tracks the user uploaded can go into an album, and removing one renumbers the rest so the tracklist stays 1..n. --- .../Albums/AddTrackToAlbum.cs | 82 +++++++++++++++++++ .../Musify.Application/Albums/CreateAlbum.cs | 62 ++++++++++++++ .../Musify.Application/Albums/DeleteAlbum.cs | 50 +++++++++++ .../Musify.Application/Albums/GetAlbumById.cs | 29 +++++++ .../Albums/GetAlbumTracks.cs | 51 ++++++++++++ .../Musify.Application/Albums/GetAlbums.cs | 44 ++++++++++ .../Albums/GetAlbumsByUserId.cs | 44 ++++++++++ .../Albums/RemoveTrackFromAlbum.cs | 59 +++++++++++++ .../Responses/AlbumApplicationResponse.cs | 34 ++++++++ .../Musify.Application/Albums/UpdateAlbum.cs | 63 ++++++++++++++ 10 files changed, 518 insertions(+) create mode 100644 backend/Musify.Application/Albums/AddTrackToAlbum.cs create mode 100644 backend/Musify.Application/Albums/CreateAlbum.cs create mode 100644 backend/Musify.Application/Albums/DeleteAlbum.cs create mode 100644 backend/Musify.Application/Albums/GetAlbumById.cs create mode 100644 backend/Musify.Application/Albums/GetAlbumTracks.cs create mode 100644 backend/Musify.Application/Albums/GetAlbums.cs create mode 100644 backend/Musify.Application/Albums/GetAlbumsByUserId.cs create mode 100644 backend/Musify.Application/Albums/RemoveTrackFromAlbum.cs create mode 100644 backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs create mode 100644 backend/Musify.Application/Albums/UpdateAlbum.cs diff --git a/backend/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Musify.Application/Albums/AddTrackToAlbum.cs new file mode 100644 index 0000000..84975fe --- /dev/null +++ b/backend/Musify.Application/Albums/AddTrackToAlbum.cs @@ -0,0 +1,82 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Albums +{ + public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + + public class AddTrackToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.UserAlbums + .AsNoTracking() + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album is null) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); + return Error.Unauthorized(); + } + + var track = await database.LocalTracks + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (track is null) + { + logger.LogInformation("Local track {TrackId} not found", request.TrackId); + return Error.NotFound(description: "Only tracks you uploaded can be added to an album."); + } + + if (track.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); + return Error.Unauthorized(); + } + + var alreadyAdded = await database.AlbumHasTracks + .AsNoTracking() + .AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken); + if (alreadyAdded) + { + logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId); + return Error.Conflict(description: "Track is already in the album."); + } + + var albumTracksQuery = database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + + var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken); + var nextTrackNumber = hasTracks + ? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1 + : 1; + + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = request.TrackId, + TrackNumber = nextTrackNumber + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}", + request.TrackId, request.AlbumId, nextTrackNumber); + + return new Success(); + } + } +} diff --git a/backend/Musify.Application/Albums/CreateAlbum.cs b/backend/Musify.Application/Albums/CreateAlbum.cs new file mode 100644 index 0000000..6a0d38a --- /dev/null +++ b/backend/Musify.Application/Albums/CreateAlbum.cs @@ -0,0 +1,62 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Albums +{ + public record CreateAlbumCommand( + long UserId, + string Title, + string? Description, + int? ReleaseYear) + : ICommand>; + + public class CreateAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); + } + + var title = request.Title.Trim(); + + var album = new UserAlbum + { + OwnerUserId = request.UserId, + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Description = request.Description, + ReleaseYear = request.ReleaseYear + }; + + await database.UserAlbums.AddAsync(album, cancellationToken); + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId); + return Error.Failure(description: $"Failed to create album for user {request.UserId}"); + } + + logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + + return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + } + } +} diff --git a/backend/Musify.Application/Albums/DeleteAlbum.cs b/backend/Musify.Application/Albums/DeleteAlbum.cs new file mode 100644 index 0000000..3817398 --- /dev/null +++ b/backend/Musify.Application/Albums/DeleteAlbum.cs @@ -0,0 +1,50 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record DeleteAlbumCommand(long UserId, Guid AlbumId) + : ICommand>; + + public class DeleteAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.UserAlbums + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album is null) + { + logger.LogDebug("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); + return Error.Unauthorized(); + } + + database.UserAlbums.Remove(album); + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId); + return Error.Failure(description: $"Failed to delete album {request.AlbumId}"); + } + + logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); + + return new Success(); + } + } +} diff --git a/backend/Musify.Application/Albums/GetAlbumById.cs b/backend/Musify.Application/Albums/GetAlbumById.cs new file mode 100644 index 0000000..ff79572 --- /dev/null +++ b/backend/Musify.Application/Albums/GetAlbumById.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record GetAlbumByIdQuery(Guid Id) + : IQuery>; + + public class GetAlbumByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) + { + var album = await database.UserAlbums + .AsNoTracking() + .Where(a => a.Id == request.Id) + .Select(a => new { Album = a, TrackCount = a.AlbumTracks.Count }) + .SingleOrDefaultAsync(cancellationToken); + + if (album is null) + return Error.NotFound(); + + return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount); + } + } +} diff --git a/backend/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Musify.Application/Albums/GetAlbumTracks.cs new file mode 100644 index 0000000..f70c35e --- /dev/null +++ b/backend/Musify.Application/Albums/GetAlbumTracks.cs @@ -0,0 +1,51 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Albums +{ + public record GetAlbumTracksQuery( + Guid AlbumId, + int PageNumber, + int PageSize) + : IQuery>>; + + public class GetAlbumTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) + { + var albumExists = await database.Albums + .AsNoTracking() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + if (!albumExists) + return Error.NotFound(); + + var tracksQuery = database.AlbumHasTracks + .AsNoTracking() + .Include(albumTrack => ((ExternalTrack)albumTrack.Track).TrackArtists) + .ThenInclude(trackArtist => trackArtist.Artist) + .Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner) + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + + var pagedEntities = await tracksQuery + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedTracks = new StaticPagedList( + pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedTracks); + } + } +} diff --git a/backend/Musify.Application/Albums/GetAlbums.cs b/backend/Musify.Application/Albums/GetAlbums.cs new file mode 100644 index 0000000..88238f0 --- /dev/null +++ b/backend/Musify.Application/Albums/GetAlbums.cs @@ -0,0 +1,44 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Albums +{ + public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) + : IQuery>>; + + public class GetAlbumsQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + { + var albumsQuery = database.UserAlbums + .AsNoTracking() + .AsQueryable(); + + if (!string.IsNullOrEmpty(request.Title)) + { + var normalizedTitle = request.Title.Trim().ToUpperInvariant(); + albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); + } + + var totalCount = await albumsQuery.CountAsync(cancellationToken); + + var pagedEntities = await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .Select(album => new { Album = album, TrackCount = album.AlbumTracks.Count }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedAlbums = new StaticPagedList( + pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedAlbums); + } + } +} diff --git a/backend/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Musify.Application/Albums/GetAlbumsByUserId.cs new file mode 100644 index 0000000..f28249a --- /dev/null +++ b/backend/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -0,0 +1,44 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Albums +{ + public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetAlbumsByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + return Error.NotFound(description: $"User {request.UserId} not found"); + + var albumsQuery = database.UserAlbums + .AsNoTracking() + .Where(album => album.OwnerUserId == request.UserId); + + var totalCount = await albumsQuery.CountAsync(cancellationToken); + + var pagedEntities = await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .Select(album => new { Album = album, TrackCount = album.AlbumTracks.Count }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedAlbums = new StaticPagedList( + pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedAlbums); + } + } +} diff --git a/backend/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Musify.Application/Albums/RemoveTrackFromAlbum.cs new file mode 100644 index 0000000..b35d505 --- /dev/null +++ b/backend/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -0,0 +1,59 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + + public class RemoveTrackFromAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.UserAlbums + .AsNoTracking() + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album is null) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); + return Error.Unauthorized(); + } + + var albumTracks = await database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ToListAsync(cancellationToken); + + var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); + if (link is null) + { + logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId); + return Error.NotFound(description: "Track is not in the album."); + } + + database.AlbumHasTracks.Remove(link); + + var trackNumber = 1; + foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) + remaining.TrackNumber = trackNumber++; + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); + + return new Success(); + } + } +} diff --git a/backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs new file mode 100644 index 0000000..d47fb71 --- /dev/null +++ b/backend/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -0,0 +1,34 @@ +using Musify.Domain.Entities; + +namespace Musify.Application.Albums.Responses +{ + public record AlbumApplicationResponse( + Guid Id, + string Title, + string? Description, + int? ReleaseYear, + long OwnerUserId, + int TrackCount, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + DateTime CreatedAt, + DateTime UpdatedAt) + { + public static AlbumApplicationResponse FromEntity(UserAlbum album, int trackCount) + { + return new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + trackCount, + album.Pictures?.SmallName, + album.Pictures?.MediumName, + album.Pictures?.LargeName, + album.CreatedAt, + album.UpdatedAt); + } + } +} diff --git a/backend/Musify.Application/Albums/UpdateAlbum.cs b/backend/Musify.Application/Albums/UpdateAlbum.cs new file mode 100644 index 0000000..961c4b8 --- /dev/null +++ b/backend/Musify.Application/Albums/UpdateAlbum.cs @@ -0,0 +1,63 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record UpdateAlbumCommand( + long UserId, + Guid AlbumId, + string Title, + string? Description, + int? ReleaseYear) + : ICommand>; + + public class UpdateAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.UserAlbums + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album is null) + { + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); + } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); + return Error.Unauthorized(); + } + + var title = request.Title.Trim(); + + album.Title = title; + album.NormalizedTitle = title.ToUpperInvariant(); + album.Description = request.Description; + album.ReleaseYear = request.ReleaseYear; + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId); + return Error.Failure(description: $"Failed to update album {request.AlbumId}"); + } + + var trackCount = await database.AlbumHasTracks + .AsNoTracking() + .CountAsync(albumTrack => albumTrack.AlbumId == album.Id, cancellationToken); + + return AlbumApplicationResponse.FromEntity(album, trackCount); + } + } +}