Reorganize backend to mirror Musify.slnx folder structure
Moves the six backend projects on disk into Core/ and Hosts/ subfolders, matching the solution folders already defined in Musify.slnx, so the file explorer reflects the same layout as the solution: backend/Core/Musify.Domain backend/Core/Musify.Application backend/Core/Musify.Infrastructure backend/Hosts/Musify.Api backend/Hosts/Musify.Worker backend/Hosts/Musify.StreamingGateway Updates every reference to the old paths: ProjectReference entries in Musify.Api.csproj and Musify.Worker.csproj, the three Dockerfiles, deploy/scripts/migrate.ps1|sh, deploy/zitadel/provision.ps1, deploy/up.ps1, deploy/README.md and docs/development.md. Also fixes a stale Dockerfile comment pointing at a compose file that no longer exists. Verified with `dotnet build Musify.slnx` against the new layout.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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<ErrorOr<Success>>;
|
||||
|
||||
public class AddTrackToAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTrackToAlbumCommandHandler> logger)
|
||||
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> 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 trackExists = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (!trackExists)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
||||
}
|
||||
|
||||
var localTrack = await database.LocalTracks
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (localTrack is not null && localTrack.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class CreateAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<CreateAlbumCommandHandler> logger)
|
||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorOr<Success>>;
|
||||
|
||||
public class DeleteAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<DeleteAlbumCommandHandler> logger)
|
||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class GetAlbumByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> 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,
|
||||
CoverTrackIds = a.AlbumTracks
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToList()
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (album is null)
|
||||
return Error.NotFound();
|
||||
|
||||
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
|
||||
public class GetAlbumTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> 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<TrackApplicationResponse>(
|
||||
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
|
||||
|
||||
public class GetAlbumsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumsQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> 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,
|
||||
CoverTrackIds = album.AlbumTracks
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToList()
|
||||
})
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
|
||||
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
|
||||
|
||||
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> 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,
|
||||
CoverTrackIds = album.AlbumTracks
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToList()
|
||||
})
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
|
||||
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse> >>;
|
||||
|
||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var listenedTrackIds = database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == request.UserId);
|
||||
|
||||
var ranked = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Join(
|
||||
listenedTrackIds,
|
||||
albumTrack => albumTrack.TrackId,
|
||||
history => history.TrackId,
|
||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt } )
|
||||
.GroupBy(entry => entry.AlbumId)
|
||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) } )
|
||||
.OrderByDescending(entry => entry.LastListenedAt)
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (ranked.Count == 0)
|
||||
return new List<AlbumApplicationResponse>();
|
||||
|
||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||
|
||||
var albums = await database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(album => albumIds.Contains(album.Id))
|
||||
.Select(album => new
|
||||
{
|
||||
Album = album,
|
||||
TrackCount = album.AlbumTracks.Count,
|
||||
CoverTrackIds = album.AlbumTracks
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToList()
|
||||
} )
|
||||
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
||||
|
||||
return ranked
|
||||
.Where(entry => albums.ContainsKey(entry.AlbumId))
|
||||
.Select(entry => albums[entry.AlbumId])
|
||||
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorOr<Success>>;
|
||||
|
||||
public class RemoveTrackFromAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
|
||||
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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,
|
||||
IReadOnlyList<Guid> CoverTrackIds,
|
||||
string? YouTubeAlbumId = null,
|
||||
string? ThumbnailUrl = null)
|
||||
{
|
||||
public const int CoverTrackCount = 4;
|
||||
|
||||
public static AlbumApplicationResponse FromEntity(
|
||||
UserAlbum album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
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,
|
||||
coverTrackIds ?? []);
|
||||
}
|
||||
|
||||
public static AlbumApplicationResponse FromEntity(
|
||||
Album album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
var externalAlbum = album as ExternalAlbum;
|
||||
return new AlbumApplicationResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
album is UserAlbum userAlbum ? userAlbum.OwnerUserId : 0,
|
||||
trackCount,
|
||||
album.Pictures?.SmallName,
|
||||
album.Pictures?.MediumName,
|
||||
album.Pictures?.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? [],
|
||||
externalAlbum?.ExternalId,
|
||||
externalAlbum?.ThumbnailUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class UpdateAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<UpdateAlbumCommandHandler> logger)
|
||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> 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 albumTracks = database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(albumTrack => albumTrack.AlbumId == album.Id);
|
||||
|
||||
var trackCount = await albumTracks.CountAsync(cancellationToken);
|
||||
var coverTrackIds = await albumTracks
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user