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