Make album cover pictures mandatory, unify cover handling with playlists
Albums previously had no cover concept at all: no picture upload
flow, no GET /albums/{id}/cover, and AlbumApplicationResponse's
image-key fields were plumbed but never populated. Description,
Pictures and TrackCount already lived on the shared Album base entity
for both local and external albums, so unifying the cover story only
required building the missing local-album picture pipeline.
Backend, cloning PlayList's existing async picture-processing
architecture exactly (MassTransit routing slip + saga), but making
the picture mandatory instead of optional (matching Track's pattern):
- AlbumPictures moves from all-nullable to required fields.
- New AlbumConfiguration (routes/sizes), deliberately without
PlayList's preset-picture fallback since a picture is now required
up front.
- CreateAlbum now takes a mandatory PictureIntentId, validates and
consumes the upload intent, and kicks off the same
copy-to-final -> resize -> upload -> update-entity routing slip
PlayLists use, via a new AlbumProcessingState saga that marks the
album Failed on a picture-processing failure.
- UpdateAlbum takes an optional NewPictureIntentId to replace the
cover later.
- New GET /albums/{id}/cover and POST /albums/upload-picture
endpoints, mirroring the playlist ones.
- New EF migration for the AlbumProcessingState saga table.
Frontend:
- AlbumForm gains a cover dropzone, required on create and optional
on edit (via ImageDropzone's new `required` prop), uploaded through
the same presigned-upload helper playlists use.
- New /api/albums/{id}/cover proxy route.
- PlaylistArt gains a generic coverUrl prop so AlbumHeader and the
album list/detail cards render the real uploaded cover instead of
only the track mosaic fallback.
Delete-time picture cleanup for albums is intentionally left as a
follow-up, matching the existing gap in playlist deletion.
This commit is contained in:
@@ -2,9 +2,14 @@ using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
@@ -12,12 +17,18 @@ namespace Musify.Application.Albums
|
||||
long UserId,
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear)
|
||||
int? ReleaseYear,
|
||||
Guid PictureIntentId)
|
||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class CreateAlbumCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
ILogger<CreateAlbumCommandHandler> logger)
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<CreateAlbumCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
AlbumConfiguration albumConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
||||
@@ -31,6 +42,12 @@ namespace Musify.Application.Albums
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
|
||||
if (pictureIntent.IsError)
|
||||
return pictureIntent.Errors;
|
||||
|
||||
var intent = pictureIntent.Value;
|
||||
var title = request.Title.Trim();
|
||||
|
||||
var album = new UserAlbum
|
||||
@@ -39,11 +56,23 @@ namespace Musify.Application.Albums
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
Description = request.Description,
|
||||
ReleaseYear = request.ReleaseYear
|
||||
ReleaseYear = request.ReleaseYear,
|
||||
Pictures = new AlbumPictures
|
||||
{
|
||||
OriginalName = intent.ObjectName,
|
||||
SmallName = intent.ObjectName,
|
||||
MediumName = intent.ObjectName,
|
||||
LargeName = intent.ObjectName
|
||||
}
|
||||
};
|
||||
|
||||
await database.UserAlbums.AddAsync(album, cancellationToken);
|
||||
|
||||
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
||||
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
|
||||
if (publishResult.IsError)
|
||||
return publishResult.Errors;
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
@@ -58,5 +87,42 @@ namespace Musify.Application.Albums
|
||||
|
||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
||||
}
|
||||
|
||||
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
|
||||
Guid albumId,
|
||||
UploadIntent pictureIntent,
|
||||
string finalPictureKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new CreateAlbumResourcesEvent(
|
||||
albumId,
|
||||
pictureIntent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
new ImageSize(
|
||||
albumConfiguration.Routes.SmallPicturesPath,
|
||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
||||
new ImageSize(
|
||||
albumConfiguration.Routes.MediumPicturesPath,
|
||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
||||
new ImageSize(
|
||||
albumConfiguration.Routes.LargePicturesPath,
|
||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
||||
cancellationToken);
|
||||
return new Success();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
|
||||
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MimeMapping;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
|
||||
: IQuery<ErrorOr<AlbumCoverLocation>>;
|
||||
|
||||
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
|
||||
|
||||
public class GetAlbumCoverQueryHandler(
|
||||
IDatabase database,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
AlbumConfiguration albumConfiguration)
|
||||
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var pictures = await database.Albums.AsNoTracking()
|
||||
.Where(a => a.Id == request.AlbumId)
|
||||
.Select(a => a.Pictures)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (pictures is null)
|
||||
return Error.NotFound();
|
||||
|
||||
var routes = albumConfiguration.Routes;
|
||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
||||
{
|
||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
||||
return Error.NotFound();
|
||||
|
||||
var contentType = MimeUtility.GetMimeMapping(name);
|
||||
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
|
||||
}
|
||||
|
||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public record RequestAlbumPictureUploadCommand(
|
||||
long UserId,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null)
|
||||
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
|
||||
|
||||
public class RequestAlbumPictureUploadCommandHandler(
|
||||
IDatabase database,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
IStorageService storageService,
|
||||
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
AlbumConfiguration albumConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
{
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||
|
||||
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
|
||||
|
||||
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
|
||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
||||
|
||||
try
|
||||
{
|
||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket,
|
||||
tempKey,
|
||||
request.ContentType,
|
||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
||||
cancellationToken);
|
||||
|
||||
await using var transaction = await database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
||||
if (quotaCheck.IsError)
|
||||
{
|
||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
||||
return quotaCheck.Errors;
|
||||
}
|
||||
|
||||
var intent = new UploadIntent
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = tempKey,
|
||||
ObjectName = objectName,
|
||||
ContentType = request.ContentType,
|
||||
ExpectedSizeBytes = effectiveSizeBytes,
|
||||
Purpose = UploadIntentPurpose.AlbumPicture,
|
||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
||||
};
|
||||
|
||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
|
||||
|
||||
return new AlbumPictureUploadResponse(
|
||||
intent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
tempKey,
|
||||
objectName,
|
||||
request.ContentType,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||
uploadUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
|
||||
return Error.Failure(description: "Failed to generate upload URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
public record AlbumPictureUploadResponse(
|
||||
Guid IntentId,
|
||||
string Bucket,
|
||||
string Key,
|
||||
string PictureName,
|
||||
string ContentType,
|
||||
int ExpiresInSeconds,
|
||||
string UploadUrl);
|
||||
}
|
||||
@@ -2,7 +2,13 @@ using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
@@ -12,12 +18,18 @@ namespace Musify.Application.Albums
|
||||
Guid AlbumId,
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear)
|
||||
int? ReleaseYear,
|
||||
Guid? NewPictureIntentId)
|
||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class UpdateAlbumCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
ILogger<UpdateAlbumCommandHandler> logger)
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<UpdateAlbumCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
AlbumConfiguration albumConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||
@@ -43,6 +55,38 @@ namespace Musify.Application.Albums
|
||||
album.Description = request.Description;
|
||||
album.ReleaseYear = request.ReleaseYear;
|
||||
|
||||
UploadIntent? pictureIntent = null;
|
||||
string? finalPictureKey = null;
|
||||
|
||||
if (request.NewPictureIntentId.HasValue)
|
||||
{
|
||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
||||
if (validation.IsError)
|
||||
return validation.Errors;
|
||||
|
||||
pictureIntent = validation.Value;
|
||||
album.Pictures = new AlbumPictures
|
||||
{
|
||||
OriginalName = pictureIntent.ObjectName,
|
||||
SmallName = pictureIntent.ObjectName,
|
||||
MediumName = pictureIntent.ObjectName,
|
||||
LargeName = pictureIntent.ObjectName
|
||||
};
|
||||
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||
}
|
||||
|
||||
database.UserAlbums.Update(album);
|
||||
|
||||
if (pictureIntent != null && finalPictureKey != null)
|
||||
{
|
||||
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
|
||||
album.Id, pictureIntent, finalPictureKey, cancellationToken);
|
||||
if (publishResult.IsError)
|
||||
return publishResult.Errors;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
@@ -64,7 +108,45 @@ namespace Musify.Application.Albums
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
||||
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
|
||||
}
|
||||
|
||||
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
|
||||
Guid albumId,
|
||||
UploadIntent pictureIntent,
|
||||
string finalPictureKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new UpdateAlbumPictureSourceEvent(
|
||||
albumId,
|
||||
pictureIntent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
new ImageSize(
|
||||
albumConfiguration.Routes.SmallPicturesPath,
|
||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
||||
new ImageSize(
|
||||
albumConfiguration.Routes.MediumPicturesPath,
|
||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
||||
new ImageSize(
|
||||
albumConfiguration.Routes.LargePicturesPath,
|
||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
||||
cancellationToken);
|
||||
return new Success();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
|
||||
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user