Deduplicate picture flows: shared upload issuer, cover query and picture source change

This commit is contained in:
2026-09-24 12:04:54 +02:00
parent 4b147d9ee3
commit 75d0c0817e
55 changed files with 667 additions and 1163 deletions
@@ -5,9 +5,10 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
@@ -26,9 +27,7 @@ public class CreateAlbumCommandHandler(
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger, ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, AlbumConfiguration albumConfiguration)
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
@@ -37,17 +36,14 @@ public class CreateAlbumCommandHandler(
.AsNoTracking() .AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken); .AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
{ return AppErrors.NotFound("User", request.UserId);
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (pictureIntent.IsError) if (prepared.IsError)
return pictureIntent.Errors; return prepared.Errors;
var intent = pictureIntent.Value; var picture = prepared.Value;
var title = request.Title.Trim(); var title = request.Title.Trim();
var album = new Album var album = new Album
@@ -57,57 +53,20 @@ public class CreateAlbumCommandHandler(
NormalizedTitle = TextNormalizer.Normalize(title), NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description, Description = request.Description,
ReleaseYear = request.ReleaseYear, ReleaseYear = request.ReleaseYear,
Pictures = EntityPictures.Pending(intent.ObjectName) Pictures = picture.Pictures
}; };
await database.Albums.AddAsync(album, cancellationToken); await database.Albums.AddAsync(album, cancellationToken);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); await eventBus.PublishAsync(
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken); new CreateAlbumResourcesEvent(
if (publishResult.IsError) album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
return publishResult.Errors; cancellationToken);
try await database.SaveChangesAsync(cancellationToken);
{
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); logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0); return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
} }
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes);
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
sizes.Small,
sizes.Medium,
sizes.Large),
cancellationToken);
return Result.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}");
}
}
} }
@@ -1,57 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
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 owned = await database.Albums.AsNoTracking()
.Where(e => e.Id == request.AlbumId && e.LifeCycleStatus == LifeCycleStatus.Active)
.Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures })
.SingleOrDefaultAsync(cancellationToken);
if (owned?.Pictures == null)
return AppErrors.NotFound("AlbumCover", request.AlbumId);
var pictures = owned.Pictures;
var routes = albumConfiguration.Routes;
var size = PictureSizeParser.Parse(request.Size);
var name = size switch
{
PictureSize.Small => pictures.SmallName,
PictureSize.Large => pictures.LargeName,
_ => pictures.MediumName
};
var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name);
// The resized files only exist once processing has finished; until then serve the original.
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
{
name = pictures.OriginalName;
key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name);
}
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return AppErrors.NotFound("AlbumCover", request.AlbumId);
var contentType = MimeUtility.GetMimeMapping(name);
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
}
}
@@ -1,12 +1,8 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums;
@@ -16,89 +12,13 @@ public record RequestAlbumPictureUploadCommand(
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<AlbumPictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler( public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
IDatabase database, : ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
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) public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
{ await issuer.IssuePictureAsync(
var userExists = await database.Users request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
.AsNoTracking() request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
.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.BuildTempPath(
uploadIntentConfiguration.TempRootPrefix, 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");
}
}
} }
@@ -6,9 +6,9 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums;
@@ -27,27 +27,16 @@ public class UpdateAlbumCommandHandler(
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger, ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, AlbumConfiguration albumConfiguration)
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.Albums var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (found.IsError)
if (album == null) return found.Errors;
{
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 AppErrors.Forbidden("Album", request.AlbumId);
}
var album = found.Value;
var title = request.Title.Trim(); var title = request.Title.Trim();
album.Title = title; album.Title = title;
@@ -55,75 +44,30 @@ public class UpdateAlbumCommandHandler(
album.Description = request.Description; album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear; album.ReleaseYear = request.ReleaseYear;
if (request.NewPictureIntentId.HasValue) if (request.NewPictureIntentId is { } intentId)
{ {
var validation = await uploadIntentValidator.ValidateAndLoadAsync( var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentConfiguration, uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); if (prepared.IsError)
if (validation.IsError) return prepared.Errors;
return validation.Errors;
var pictureIntent = validation.Value; var picture = prepared.Value;
album.Pictures = EntityPictures.Pending(pictureIntent.ObjectName); album.Pictures = picture.Pictures;
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
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);
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
{
var sizes = albumConfiguration.PicturesSizes.ToImageSizes(albumConfiguration.Routes);
await eventBus.PublishAsync( await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent( new UpdateAlbumPictureSourceEvent(
albumId, album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
sizes.Small,
sizes.Medium,
sizes.Large),
cancellationToken); cancellationToken);
return Result.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}");
} }
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return await database.Albums
.AsNoTracking()
.Where(a => a.Id == album.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
} }
} }
@@ -11,6 +11,7 @@ public static class ApplicationDependencyInjection
{ {
services.AddMediator(); services.AddMediator();
services.AddScoped<UploadIntentValidator>(); services.AddScoped<UploadIntentValidator>();
services.AddScoped<UploadIntentIssuer>();
services.AddScoped<TrackStreamIssuer>(); services.AddScoped<TrackStreamIssuer>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration); services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
@@ -8,6 +8,4 @@ public record CreateAlbumResourcesEvent(
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -8,6 +8,4 @@ public record CreatePlayListResourcesEvent(
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -12,6 +12,4 @@ public record CreateTrackResourcesEvent(
string AudioSourceKey, string AudioSourceKey,
string AudioDestinationKey, string AudioDestinationKey,
string AudioProcessedFolderKey, string AudioProcessedFolderKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -6,6 +6,4 @@ public record UpdateAlbumPictureEvent(
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -8,6 +8,4 @@ public record UpdateAlbumPictureSourceEvent(
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -6,6 +6,4 @@ public record UpdatePlayListPictureEvent(
Guid PlayListId, Guid PlayListId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -8,6 +8,4 @@ public record UpdatePlayListPictureSourceEvent(
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -6,6 +6,4 @@ public record UpdateTrackPictureEvent(
Guid TrackId, Guid TrackId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -0,0 +1,75 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
public enum CoverOwner
{
Track,
Album,
PlayList
}
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
: IQuery<ErrorOr<CoverLocation>>;
public record CoverLocation(string Bucket, string Key, string ContentType);
public class GetCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
{
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => (await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
_ => (await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
};
var entity = request.Owner.ToString();
if (source == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
var resizedName = request.Size switch
{
PictureSize.Small => source.Small,
PictureSize.Large => source.Large,
_ => source.Medium
};
// The resized files only exist once processing has finished; until then serve the original.
var (name, key) = !string.IsNullOrEmpty(resizedName)
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
: !string.IsNullOrEmpty(source.Original)
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
: (null, null);
if (name == null || key == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
}
}
@@ -0,0 +1,35 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
{
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
UploadIntentValidator uploadIntentValidator,
Guid intentId,
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
CancellationToken cancellationToken)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
if (validation.IsError)
return validation.Errors;
var intent = validation.Value;
return new PreparedPicture(
intent,
EntityPictures.Pending(intent.ObjectName),
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
owner.PicturesSizes.ToImageSizes(owner.Routes));
}
}
@@ -1,6 +1,6 @@
namespace Musify.Application.Albums.Responses; namespace Musify.Application.Pictures.Responses;
public record AlbumPictureUploadResponse( public record PictureUploadResponse(
Guid IntentId, Guid IntentId,
string Bucket, string Bucket,
string Key, string Key,
@@ -4,10 +4,11 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
@@ -26,106 +27,52 @@ public class CreatePlayListCommandHandler(
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger, ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration)
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
private sealed record ResolvedPlayListPictures(EntityPictures? Pictures, UploadIntent? Intent);
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
.AsNoTracking() .AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken); .AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", request.UserId);
PreparedPicture? picture = null;
if (request.PictureIntentId is { } intentId)
{ {
logger.LogWarning("User {UserId} not found", request.UserId); var prepared = await PictureSourceChange.PrepareAsync(
return Error.NotFound(description: $"User {request.UserId} not found"); uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
picture = prepared.Value;
} }
var picturesResult = await ResolvePicturesAsync(request, cancellationToken); var name = request.Name.Trim();
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList var playList = new PlayList
{ {
OwnerUserId = request.UserId, OwnerUserId = request.UserId,
Name = request.Name.Trim(), Name = name,
NormalizedName = TextNormalizer.Normalize(request.Name), NormalizedName = TextNormalizer.Normalize(name),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures, Pictures = picture?.Pictures,
Visibility = request.Visibility Visibility = request.Visibility
}; };
await database.PlayLists.AddAsync(playList, cancellationToken); await database.PlayLists.AddAsync(playList, cancellationToken);
if (resolvedPictures.Intent != null) if (picture != null)
{ {
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
}
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
CreatePlayListCommand request, CancellationToken cancellationToken)
{
if (!request.PictureIntentId.HasValue)
{
return new ResolvedPlayListPictures(null, null);
}
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken);
if (validation.IsError)
return validation.Errors;
return new ResolvedPlayListPictures(EntityPictures.Pending(validation.Value.ObjectName), validation.Value);
}
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes);
await eventBus.PublishAsync( await eventBus.PublishAsync(
new CreatePlayListResourcesEvent( new CreatePlayListResourcesEvent(
playListId, playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
sizes.Small,
sizes.Medium,
sizes.Large),
cancellationToken); cancellationToken);
return Result.Success;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
} }
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
} }
} }
@@ -1,57 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
: IQuery<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
{
var owned = await database.PlayLists.AsNoTracking()
.Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active)
.Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures })
.SingleOrDefaultAsync(cancellationToken);
if (owned?.Pictures == null)
return AppErrors.NotFound("PlayListCover", request.PlayListId);
var pictures = owned.Pictures;
var routes = playListConfiguration.Routes;
var size = PictureSizeParser.Parse(request.Size);
var name = size switch
{
PictureSize.Small => pictures.SmallName,
PictureSize.Large => pictures.LargeName,
_ => pictures.MediumName
};
var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name);
// The resized files only exist once processing has finished; until then serve the original.
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
{
name = pictures.OriginalName;
key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name);
}
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return AppErrors.NotFound("PlayListCover", request.PlayListId);
var contentType = MimeUtility.GetMimeMapping(name);
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
}
}
@@ -1,12 +1,8 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Pictures.Responses;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists;
@@ -16,89 +12,13 @@ public record RequestPlayListPictureUploadCommand(
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PlayListPictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler( public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
IDatabase database, : ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
{ await issuer.IssuePictureAsync(
var userExists = await database.Users request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
.AsNoTracking() request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
.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 = playListConfiguration.Routes.BuildTempPath(
uploadIntentConfiguration.TempRootPrefix, 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.PlayListPicture,
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} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new PlayListPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
} }
@@ -1,10 +0,0 @@
namespace Musify.Application.PlayLists.Responses;
public record PlayListPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
@@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists;
@@ -27,108 +27,54 @@ public class UpdatePlayListCommandHandler(
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger, ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration)
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{ {
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playListEntity == null) if (found.IsError)
{ return found.Errors;
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playListEntity.OwnerUserId != request.UserId) var playList = found.Value;
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return AppErrors.Forbidden("PlayList", request.PlayListId);
}
if (!string.IsNullOrWhiteSpace(request.NewName)) if (!string.IsNullOrWhiteSpace(request.NewName))
{ {
playListEntity.Name = request.NewName.Trim(); playList.Name = request.NewName.Trim();
playListEntity.NormalizedName = TextNormalizer.Normalize(request.NewName); playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
} }
// null means "unchanged"; a blank description clears it.
if (request.NewDescription != null) if (request.NewDescription != null)
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
if (request.NewVisibility is { } visibility)
playList.Visibility = visibility;
if (request.NewPictureIntentId is { } intentId)
{ {
playListEntity.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); var prepared = await PictureSourceChange.PrepareAsync(
} uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
if (request.NewVisibility.HasValue) var picture = prepared.Value;
{ playList.Pictures = picture.Pictures;
playListEntity.Visibility = request.NewVisibility.Value;
}
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken);
if (validation.IsError)
return validation.Errors;
var pictureIntent = validation.Value;
playListEntity.Pictures = EntityPictures.Pending(pictureIntent.ObjectName);
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
}
var coverTrackIds = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
var sizes = playListConfiguration.PicturesSizes.ToImageSizes(playListConfiguration.Routes);
await eventBus.PublishAsync( await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent( new UpdatePlayListPictureSourceEvent(
playListId, playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
sizes.Small,
sizes.Medium,
sizes.Large),
cancellationToken); cancellationToken);
return Result.Success;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
} }
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
return await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == playList.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
} }
} }
@@ -0,0 +1,133 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Pictures.Responses;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Services;
public sealed record UploadRequest(
UploadIntentPurpose Purpose,
PictureRoutes Routes,
string FileType,
string ContentType,
long? ExpectedSizeBytes);
public sealed record IssuedUpload(
Guid IntentId,
string Key,
string ObjectName,
string ContentType,
string UploadUrl);
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
public sealed class UploadIntentIssuer(
IDatabase database,
IStorageService storageService,
UploadIntentValidator uploadIntentValidator,
ApplicationStorageConfiguration storageConfiguration,
UploadIntentConfiguration uploadIntentConfiguration,
ILogger<UploadIntentIssuer> logger)
{
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
long userId,
IReadOnlyList<UploadRequest> requests,
CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == userId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", userId);
var planned = requests
.Select(request =>
{
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
return (Request: request, ObjectName: objectName, Key: key, Size: size);
})
.ToList();
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quota = await uploadIntentValidator.CheckQuotaAsync(
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
if (quota.IsError)
return quota.Errors;
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
var expiresAt = DateTime.UtcNow + expiresIn;
var issued = new List<IssuedUpload>(planned.Count);
foreach (var item in planned)
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken);
var intent = new UploadIntent
{
UserId = userId,
Bucket = storageConfiguration.Bucket,
Key = item.Key,
ObjectName = item.ObjectName,
ContentType = item.Request.ContentType,
ExpectedSizeBytes = item.Size,
Purpose = item.Request.Purpose,
ExpiresAt = expiresAt,
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
}
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
return issued;
}
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
{
var issued = await IssueAsync(userId, [request], cancellationToken);
return issued.IsError ? issued.Errors : issued.Value[0];
}
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
string fileType,
string contentType,
long? expectedSizeBytes,
CancellationToken cancellationToken)
{
var issued = await IssueOneAsync(
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
if (issued.IsError)
return issued.Errors;
return new PictureUploadResponse(
issued.Value.IntentId,
storageConfiguration.Bucket,
issued.Value.Key,
issued.Value.ObjectName,
issued.Value.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
issued.Value.UploadUrl);
}
private long DefaultSize(UploadIntentPurpose purpose) =>
purpose == UploadIntentPurpose.TrackAudio
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
}
@@ -2,40 +2,49 @@ using ErrorOr;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
namespace Musify.Application.Services; namespace Musify.Application.Services;
public sealed class UploadIntentValidator( public sealed class UploadIntentValidator(
IDatabase database, IDatabase database,
IStorageService storageService) IStorageService storageService,
UploadIntentConfiguration config)
{ {
public async Task<ErrorOr<Success>> CheckQuotaAsync( public async Task<ErrorOr<Success>> CheckQuotaAsync(
UploadIntentConfiguration config,
long userId, long userId,
long requiredBytes, long requiredBytes,
int requiredIntentCount, int requiredIntentCount,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var activeIntents = await database.UploadIntents var now = DateTime.UtcNow;
.AsNoTracking() var pictureDefault = config.DefaultExpectedPictureSizeBytes;
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued) var audioDefault = config.DefaultExpectedAudioSizeBytes;
.ToListAsync(cancellationToken);
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) var active = await database.UploadIntents
.AsNoTracking()
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
.GroupBy(_ => 1)
.Select(group => new
{
Count = group.Count(),
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
})
.SingleOrDefaultAsync(cancellationToken);
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes); if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
return Result.Success; return Result.Success;
} }
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync( public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
UploadIntentConfiguration config,
Guid intentId, Guid intentId,
long userId, long userId,
UploadIntentPurpose purpose, UploadIntentPurpose purpose,
@@ -27,9 +27,7 @@ public class CreateTrackCommandHandler(
IEventBus eventBus, IEventBus eventBus,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreateTrackCommandHandler> logger, ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration)
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>> : ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
{ {
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
@@ -63,13 +61,11 @@ public class CreateTrackCommandHandler(
return Error.Validation(description: "Picture and audio must use different upload intents."); return Error.Validation(description: "Picture and audio must use different upload intents.");
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
if (pictureValidation.IsError) if (pictureValidation.IsError)
return pictureValidation.Errors; return pictureValidation.Errors;
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
if (audioValidation.IsError) if (audioValidation.IsError)
return audioValidation.Errors; return audioValidation.Errors;
@@ -115,40 +111,22 @@ public class CreateTrackCommandHandler(
TrackId = trackEntity.Id TrackId = trackEntity.Id
}, cancellationToken); }, cancellationToken);
try var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
{ await eventBus.PublishAsync(
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); new CreateTrackResourcesEvent(
await eventBus.PublishAsync( trackEntity.Id,
new CreateTrackResourcesEvent( pictureIntent.Id,
trackEntity.Id, audioIntent.Id,
pictureIntent.Id, pictureIntent.Bucket,
audioIntent.Id, pictureIntent.Key,
storageConfiguration.Bucket, finalPictureKey,
pictureIntent.Key, audioIntent.Key,
finalPictureKey, finalAudioKey,
audioIntent.Key, audioProcessedFolderKey,
finalAudioKey, sizes),
audioProcessedFolderKey, cancellationToken);
sizes.Small,
sizes.Medium,
sizes.Large),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id);
return Error.Failure(description: $"Failed to create track for {request.Title}");
}
try await database.SaveChangesAsync(cancellationToken);
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save track {Title}", request.Title);
return Error.Failure(description: $"Failed to create track for {request.Title}");
}
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
} }
@@ -1,50 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using MimeMapping;
namespace Musify.Application.Tracks;
public record GetTrackCoverQuery(Guid TrackId, string Size)
: IQuery<ErrorOr<TrackCoverLocation>>;
public record TrackCoverLocation(string Bucket, string Key, string ContentType);
public class GetTrackCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: IQueryHandler<GetTrackCoverQuery, ErrorOr<TrackCoverLocation>>
{
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return AppErrors.NotFound("TrackCover", request.TrackId);
var routes = trackConfiguration.Routes;
var size = PictureSizeParser.Parse(request.Size);
var name = size switch
{
PictureSize.Small => track.SmallName,
PictureSize.Large => track.LargeName,
_ => track.MediumName
};
var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name);
if (string.IsNullOrEmpty(name) || key == null)
return AppErrors.NotFound("TrackCover", request.TrackId);
var contentType = MimeUtility.GetMimeMapping(name);
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
}
}
@@ -1,12 +1,8 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks; namespace Musify.Application.Tracks;
@@ -22,10 +18,7 @@ public record RequestTrackUploadUrlsCommand(
: ICommand<ErrorOr<TrackUploadUrlsResponse>>; : ICommand<ErrorOr<TrackUploadUrlsResponse>>;
public class RequestTrackUploadUrlsCommandHandler( public class RequestTrackUploadUrlsCommandHandler(
IDatabase database, UploadIntentIssuer issuer,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration, TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration) UploadIntentConfiguration uploadIntentConfiguration)
@@ -33,110 +26,32 @@ public class RequestTrackUploadUrlsCommandHandler(
{ {
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var routes = trackConfiguration.Routes;
.AsNoTracking() var issued = await issuer.IssueAsync(
.AnyAsync(u => u.Id == request.UserId, cancellationToken); request.UserId,
if (!userExists) [
{ new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes),
logger.LogWarning("User {UserId} not found", request.UserId); new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)
return Error.NotFound(description: $"User {request.UserId} not found"); ],
} cancellationToken);
if (issued.IsError)
return issued.Errors;
var effectivePictureSize = request.ExpectedPictureSizeBytes var picture = issued.Value[0];
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; var audio = issued.Value[1];
var effectiveAudioSize = request.ExpectedAudioSizeBytes
?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes;
var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}"; return new TrackUploadUrlsResponse(
var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; picture.IntentId,
audio.IntentId,
var tempPictureKey = trackConfiguration.Routes.BuildTempPath( storageConfiguration.Bucket,
uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName); picture.Key,
var tempAudioKey = trackConfiguration.Routes.BuildTempPath( picture.ObjectName,
uploadIntentConfiguration.TempRootPrefix, request.UserId, audioObjectName); picture.ContentType,
picture.UploadUrl,
try audio.Key,
{ audio.ObjectName,
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); audio.ContentType,
audio.UploadUrl,
var pictureUploadUrl = await storageService.GetUploadUrlAsync( uploadIntentConfiguration.UploadUrlExpiresInSeconds);
storageConfiguration.Bucket,
tempPictureKey,
request.PictureContentType,
expiresIn,
cancellationToken);
var audioUploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempAudioKey,
request.AudioContentType,
expiresIn,
cancellationToken);
var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var pictureIntent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempPictureKey,
ObjectName = pictureObjectName,
ContentType = request.PictureContentType,
ExpectedSizeBytes = effectivePictureSize,
Purpose = UploadIntentPurpose.TrackPicture,
ExpiresAt = expiresAt,
};
var audioIntent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempAudioKey,
ObjectName = audioObjectName,
ContentType = request.AudioContentType,
ExpectedSizeBytes = effectiveAudioSize,
Purpose = UploadIntentPurpose.TrackAudio,
ExpiresAt = expiresAt,
};
await database.UploadIntents.AddAsync(pictureIntent, cancellationToken);
await database.UploadIntents.AddAsync(audioIntent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)",
pictureIntent.Id, audioIntent.Id, request.UserId);
return new TrackUploadUrlsResponse(
pictureIntent.Id,
audioIntent.Id,
storageConfiguration.Bucket,
tempPictureKey,
pictureObjectName,
request.PictureContentType,
pictureUploadUrl,
tempAudioKey,
audioObjectName,
request.AudioContentType,
audioUploadUrl,
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URLs");
}
} }
} }
@@ -21,9 +21,7 @@ internal class PublishAlbumPictureProcessingEventActivity(
arguments.AlbumId, arguments.AlbumId,
arguments.Bucket, arguments.Bucket,
arguments.FinalPictureKey, arguments.FinalPictureKey,
arguments.Small, arguments.Sizes),
arguments.Medium,
arguments.Large),
executeContext.CancellationToken); executeContext.CancellationToken);
logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId);
@@ -21,9 +21,7 @@ internal class PublishPlayListPictureProcessingEventActivity(
arguments.PlayListId, arguments.PlayListId,
arguments.Bucket, arguments.Bucket,
arguments.FinalPictureKey, arguments.FinalPictureKey,
arguments.Small, arguments.Sizes),
arguments.Medium,
arguments.Large),
executeContext.CancellationToken); executeContext.CancellationToken);
logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId);
@@ -21,9 +21,7 @@ internal class PublishTrackProcessingEventsActivity(
arguments.TrackId, arguments.TrackId,
arguments.Bucket, arguments.Bucket,
arguments.FinalPictureKey, arguments.FinalPictureKey,
arguments.Small, arguments.Sizes),
arguments.Medium,
arguments.Large),
executeContext.CancellationToken); executeContext.CancellationToken);
await publishEndpoint.Publish( await publishEndpoint.Publish(
@@ -6,6 +6,4 @@ public record PublishAlbumPictureProcessingEventArguments(
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string FinalPictureKey, string FinalPictureKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -6,6 +6,4 @@ public record PublishPlayListPictureProcessingEventArguments(
Guid PlayListId, Guid PlayListId,
string Bucket, string Bucket,
string FinalPictureKey, string FinalPictureKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -8,6 +8,4 @@ public record PublishTrackProcessingEventsArguments(
string FinalPictureKey, string FinalPictureKey,
string FinalAudioKey, string FinalAudioKey,
string AudioDestinationFolderKey, string AudioDestinationFolderKey,
ImageSize Small, ImageSizes Sizes);
ImageSize Medium,
ImageSize Large);
@@ -19,9 +19,7 @@ public class AlbumPictureSourceRoutingSlipBuilder
message.Bucket, message.Bucket,
message.PictureSourceKey, message.PictureSourceKey,
message.PictureDestinationKey, message.PictureDestinationKey,
message.Small, message.Sizes,
message.Medium,
message.Large,
correlationId, correlationId,
RoutingSlipVariableNames.ProcessKinds.AlbumCreation); RoutingSlipVariableNames.ProcessKinds.AlbumCreation);
@@ -32,9 +30,7 @@ public class AlbumPictureSourceRoutingSlipBuilder
message.Bucket, message.Bucket,
message.PictureSourceKey, message.PictureSourceKey,
message.PictureDestinationKey, message.PictureDestinationKey,
message.Small, message.Sizes,
message.Medium,
message.Large,
correlationId, correlationId,
RoutingSlipVariableNames.ProcessKinds.AlbumPicture); RoutingSlipVariableNames.ProcessKinds.AlbumPicture);
@@ -44,9 +40,7 @@ public class AlbumPictureSourceRoutingSlipBuilder
string bucket, string bucket,
string pictureSourceKey, string pictureSourceKey,
string pictureDestinationKey, string pictureDestinationKey,
ImageSize small, ImageSizes sizes,
ImageSize medium,
ImageSize large,
Guid? correlationId, Guid? correlationId,
string processKind) string processKind)
{ {
@@ -78,9 +72,7 @@ public class AlbumPictureSourceRoutingSlipBuilder
albumId, albumId,
bucket, bucket,
pictureDestinationKey, pictureDestinationKey,
small, sizes));
medium,
large));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, albumId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, albumId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind);
@@ -53,9 +53,7 @@ public class CreateTrackRoutingSlipBuilder
message.PictureDestinationKey, message.PictureDestinationKey,
message.AudioDestinationKey, message.AudioDestinationKey,
message.AudioProcessedFolderKey, message.AudioProcessedFolderKey,
message.Small, message.Sizes));
message.Medium,
message.Large));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackCreation); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
@@ -17,9 +17,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu
var routingSlipBuilder = BuildPictureWorkflow( var routingSlipBuilder = BuildPictureWorkflow(
message.Bucket, message.Bucket,
message.SourceKey, message.SourceKey,
message.Small, message.Sizes,
message.Medium,
message.Large,
correlationId); correlationId);
routingSlipBuilder.AddActivity( routingSlipBuilder.AddActivity(
@@ -46,9 +44,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu
var routingSlipBuilder = BuildPictureWorkflow( var routingSlipBuilder = BuildPictureWorkflow(
message.Bucket, message.Bucket,
message.SourceKey, message.SourceKey,
message.Small, message.Sizes,
message.Medium,
message.Large,
correlationId); correlationId);
routingSlipBuilder.AddActivity( routingSlipBuilder.AddActivity(
@@ -75,9 +71,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu
var routingSlipBuilder = BuildPictureWorkflow( var routingSlipBuilder = BuildPictureWorkflow(
message.Bucket, message.Bucket,
message.SourceKey, message.SourceKey,
message.Small, message.Sizes,
message.Medium,
message.Large,
correlationId); correlationId);
routingSlipBuilder.AddActivity( routingSlipBuilder.AddActivity(
@@ -102,9 +96,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu
private RoutingSlipBuilder BuildPictureWorkflow( private RoutingSlipBuilder BuildPictureWorkflow(
string bucket, string bucket,
string sourceKey, string sourceKey,
ImageSize small, ImageSizes sizes,
ImageSize medium,
ImageSize large,
Guid? correlationId) Guid? correlationId)
{ {
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
@@ -135,7 +127,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu
ActivityNames.UploadSmall, ActivityNames.UploadSmall,
bucket, bucket,
RoutingSlipVariableNames.Picture.SmallResizedFilePath, RoutingSlipVariableNames.Picture.SmallResizedFilePath,
small); sizes.Small);
AddResizeActivity( AddResizeActivity(
routingSlipBuilder, routingSlipBuilder,
@@ -143,7 +135,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu
ActivityNames.UploadMedium, ActivityNames.UploadMedium,
bucket, bucket,
RoutingSlipVariableNames.Picture.MediumResizedFilePath, RoutingSlipVariableNames.Picture.MediumResizedFilePath,
medium); sizes.Medium);
AddResizeActivity( AddResizeActivity(
routingSlipBuilder, routingSlipBuilder,
@@ -151,7 +143,7 @@ public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfigu
ActivityNames.UploadLarge, ActivityNames.UploadLarge,
bucket, bucket,
RoutingSlipVariableNames.Picture.LargeResizedFilePath, RoutingSlipVariableNames.Picture.LargeResizedFilePath,
large); sizes.Large);
return routingSlipBuilder; return routingSlipBuilder;
} }
@@ -19,9 +19,7 @@ public class PlayListPictureSourceRoutingSlipBuilder
message.Bucket, message.Bucket,
message.PictureSourceKey, message.PictureSourceKey,
message.PictureDestinationKey, message.PictureDestinationKey,
message.Small, message.Sizes,
message.Medium,
message.Large,
correlationId, correlationId,
RoutingSlipVariableNames.ProcessKinds.PlayListCreation); RoutingSlipVariableNames.ProcessKinds.PlayListCreation);
@@ -32,9 +30,7 @@ public class PlayListPictureSourceRoutingSlipBuilder
message.Bucket, message.Bucket,
message.PictureSourceKey, message.PictureSourceKey,
message.PictureDestinationKey, message.PictureDestinationKey,
message.Small, message.Sizes,
message.Medium,
message.Large,
correlationId, correlationId,
RoutingSlipVariableNames.ProcessKinds.PlayListPicture); RoutingSlipVariableNames.ProcessKinds.PlayListPicture);
@@ -44,9 +40,7 @@ public class PlayListPictureSourceRoutingSlipBuilder
string bucket, string bucket,
string pictureSourceKey, string pictureSourceKey,
string pictureDestinationKey, string pictureDestinationKey,
ImageSize small, ImageSizes sizes,
ImageSize medium,
ImageSize large,
Guid? correlationId, Guid? correlationId,
string processKind) string processKind)
{ {
@@ -78,9 +72,7 @@ public class PlayListPictureSourceRoutingSlipBuilder
playListId, playListId,
bucket, bucket,
pictureDestinationKey, pictureDestinationKey,
small, sizes));
medium,
large));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, playListId); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, playListId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind);
@@ -6,6 +6,8 @@ using Musify.Api.Filters;
using Musify.Application.Albums; using Musify.Application.Albums;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Pictures;
using Musify.Application.Pictures.Responses;
using Musify.Api.Models; using Musify.Api.Models;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
@@ -65,7 +67,7 @@ public static class AlbumEndpoints
.WithSummary("Request A Pre-Signed URL To Upload An Album Picture.") .WithSummary("Request A Pre-Signed URL To Upload An Album Picture.")
.AddEndpointFilter<ValidationFilter<RequestAlbumPictureUploadRequest>>() .AddEndpointFilter<ValidationFilter<RequestAlbumPictureUploadRequest>>()
.RequireAuthorization() .RequireAuthorization()
.Produces<AlbumPictureUploadResponse>() .Produces<PictureUploadResponse>()
.ProducesValidationProblem() .ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -179,7 +181,7 @@ public static class AlbumEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetAlbumCover( private static Task<IResult> GetAlbumCover(
IMediator mediator, IMediator mediator,
IStorageService storageService, IStorageService storageService,
HttpResponse response, HttpResponse response,
@@ -187,17 +189,7 @@ public static class AlbumEndpoints
CancellationToken cancellationToken, CancellationToken cancellationToken,
string size = "medium") string size = "medium")
{ {
var result = await mediator.Send(new GetAlbumCoverQuery(id, size), cancellationToken); return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken);
if (result.IsError)
return Results.NotFound();
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
if (stream == null)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
} }
private static async Task<IResult> RequestAlbumPictureUpload( private static async Task<IResult> RequestAlbumPictureUpload(
@@ -0,0 +1,31 @@
using Mediator;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Pictures;
namespace Musify.Api.Endpoints;
internal static class CoverEndpoint
{
public static async Task<IResult> Stream(
IMediator mediator,
IStorageService storageService,
HttpResponse response,
CoverOwner owner,
Guid id,
string size,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken);
if (result.IsError)
return Results.NotFound();
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
if (stream == null)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
}
}
@@ -4,6 +4,8 @@ using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Musify.Api.Filters; using Musify.Api.Filters;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Pictures;
using Musify.Application.Pictures.Responses;
using Musify.Application.PlayLists; using Musify.Application.PlayLists;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Api.Models; using Musify.Api.Models;
@@ -80,7 +82,7 @@ public static class PlayListEndpoints
.WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.")
.AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>() .AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>()
.RequireAuthorization() .RequireAuthorization()
.Produces<PlayListPictureUploadResponse>() .Produces<PictureUploadResponse>()
.ProducesValidationProblem() .ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -140,7 +142,7 @@ public static class PlayListEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetPlayListCover( private static Task<IResult> GetPlayListCover(
IMediator mediator, IMediator mediator,
IStorageService storageService, IStorageService storageService,
HttpResponse response, HttpResponse response,
@@ -148,17 +150,7 @@ public static class PlayListEndpoints
CancellationToken cancellationToken, CancellationToken cancellationToken,
string size = "medium") string size = "medium")
{ {
var result = await mediator.Send(new GetPlayListCoverQuery(id, size), cancellationToken); return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken);
if (result.IsError)
return Results.NotFound();
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
if (stream == null)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
} }
private static async Task<IResult> CreatePlayList( private static async Task<IResult> CreatePlayList(
@@ -4,6 +4,7 @@ using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Musify.Api.Filters; using Musify.Api.Filters;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Pictures;
using Musify.Api.Models; using Musify.Api.Models;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks; using Musify.Application.Tracks;
@@ -128,7 +129,7 @@ public static class TrackEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetTrackCover( private static Task<IResult> GetTrackCover(
IMediator mediator, IMediator mediator,
IStorageService storageService, IStorageService storageService,
HttpResponse response, HttpResponse response,
@@ -136,17 +137,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken, CancellationToken cancellationToken,
string size = "medium") string size = "medium")
{ {
var result = await mediator.Send(new GetTrackCoverQuery(id, size), cancellationToken); return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken);
if (result.IsError)
return Results.NotFound();
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
if (stream == null)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
} }
private static async Task<IResult> GetTrackStream( private static async Task<IResult> GetTrackStream(
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using Musify.Api.DataTransferObjects.Albums; using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Tests.TestSupport; using Musify.Api.Tests.TestSupport;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Pictures.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@@ -29,7 +30,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AlbumPictureUploadResponse>(TestContext.Current.CancellationToken); var body = await response.Content.ReadFromJsonAsync<PictureUploadResponse>(TestContext.Current.CancellationToken);
return body!.IntentId; return body!.IntentId;
} }
@@ -16,11 +16,9 @@ public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
private CreateAlbumCommandHandler CreateHandler() => new( private CreateAlbumCommandHandler CreateHandler() => new(
eventBus, eventBus,
Database, Database,
new UploadIntentValidator(Database, storageService), new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateAlbumCommandHandler>(), NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Storage(), TestConfigurations.Album());
TestConfigurations.Album(),
TestConfigurations.UploadIntent());
[Fact] [Fact]
public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent()
@@ -1,52 +0,0 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase
{
private GetAlbumCoverQueryHandler CreateHandler() =>
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Album());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("large", "large.webp")]
[InlineData("medium", "medium.webp")]
public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.EntityPictures.Pending("original.png"));
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Contains($"{owner.Id}", result.Value.Key);
Assert.EndsWith("original.png", result.Value.Key);
Assert.Equal("image/png", result.Value.ContentType);
}
}
@@ -20,14 +20,17 @@ public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBa
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>()) .GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload"); .Returns("https://storage.musify.test/presigned-upload");
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
return new RequestAlbumPictureUploadCommandHandler( return new RequestAlbumPictureUploadCommandHandler(
Database, new UploadIntentIssuer(
new UploadIntentValidator(Database, storageService), Database,
storageService, storageService,
NoOpLogger<RequestAlbumPictureUploadCommandHandler>(), new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(), TestConfigurations.Storage(),
TestConfigurations.Album(), config,
uploadIntentConfig ?? TestConfigurations.UploadIntent()); NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Album());
} }
[Fact] [Fact]
@@ -16,11 +16,9 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
private UpdateAlbumCommandHandler CreateHandler() => new( private UpdateAlbumCommandHandler CreateHandler() => new(
eventBus, eventBus,
Database, Database,
new UploadIntentValidator(Database, storageService), new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdateAlbumCommandHandler>(), NoOpLogger<UpdateAlbumCommandHandler>(),
TestConfigurations.Storage(), TestConfigurations.Album());
TestConfigurations.Album(),
TestConfigurations.UploadIntent());
[Fact] [Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
@@ -0,0 +1,104 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Pictures;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Pictures;
public sealed class GetCoverQueryHandlerTests : HandlerTestBase
{
private GetCoverQueryHandler CreateHandler() => new(
Database,
TestConfigurations.Storage("covers-bucket"),
TestConfigurations.Track(),
TestConfigurations.Album(),
TestConfigurations.PlayList());
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
Assert.Equal("image/webp", result.Value.ContentType);
}
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
[Fact]
public async Task Handle_ProcessedPlayList_ReturnsRequestedSize()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.EndsWith("large.webp", result.Value.Key);
}
[Theory]
[InlineData(CoverOwner.Track)]
[InlineData(CoverOwner.Album)]
[InlineData(CoverOwner.PlayList)]
public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner)
{
var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png"));
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Contains($"{owner.Id}", result.Value.Key);
Assert.EndsWith("original.png", result.Value.Key);
Assert.Equal("image/png", result.Value.ContentType);
}
[Fact]
public async Task Handle_RemovingAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
album.LifeCycleStatus = LifeCycleStatus.Removing;
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
@@ -16,11 +16,9 @@ public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
private CreatePlayListCommandHandler CreateHandler() => new( private CreatePlayListCommandHandler CreateHandler() => new(
eventBus, eventBus,
Database, Database,
new UploadIntentValidator(Database, storageService), new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreatePlayListCommandHandler>(), NoOpLogger<CreatePlayListCommandHandler>(),
TestConfigurations.Storage(), TestConfigurations.PlayList());
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
[Fact] [Fact]
public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures()
@@ -1,50 +0,0 @@
using ErrorOr;
using Musify.Application.PlayLists;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
{
private GetPlayListCoverQueryHandler CreateHandler() =>
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("large", "large.webp")]
[InlineData("medium", "medium.webp")]
public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PlayListWithoutPictures_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
playList.Pictures = null;
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
@@ -20,14 +20,17 @@ public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTes
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>()) .GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload"); .Returns("https://storage.musify.test/presigned-upload");
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
return new RequestPlayListPictureUploadCommandHandler( return new RequestPlayListPictureUploadCommandHandler(
Database, new UploadIntentIssuer(
new UploadIntentValidator(Database, storageService), Database,
storageService, storageService,
NoOpLogger<RequestPlayListPictureUploadCommandHandler>(), new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(), TestConfigurations.Storage(),
TestConfigurations.PlayList(), config,
uploadIntentConfig ?? TestConfigurations.UploadIntent()); NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.PlayList());
} }
[Fact] [Fact]
@@ -16,11 +16,9 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
private UpdatePlayListCommandHandler CreateHandler() => new( private UpdatePlayListCommandHandler CreateHandler() => new(
eventBus, eventBus,
Database, Database,
new UploadIntentValidator(Database, storageService), new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdatePlayListCommandHandler>(), NoOpLogger<UpdatePlayListCommandHandler>(),
TestConfigurations.Storage(), TestConfigurations.PlayList());
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
[Fact] [Fact]
public async Task Handle_Owner_RenamesPlayList() public async Task Handle_Owner_RenamesPlayList()
@@ -1,4 +1,5 @@
using ErrorOr; using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
@@ -13,7 +14,8 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
private readonly IStorageService storageService = Substitute.For<IStorageService>(); private readonly IStorageService storageService = Substitute.For<IStorageService>();
private readonly Domain.Entities.User owner = TestEntities.User(); private readonly Domain.Entities.User owner = TestEntities.User();
private UploadIntentValidator CreateValidator() => new(Database, storageService); private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) =>
new(Database, storageService, config ?? TestConfigurations.UploadIntent());
[Fact] [Fact]
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
@@ -25,7 +27,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
await SeedAsync(owner, intent); await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.False(result.IsError); Assert.False(result.IsError);
Assert.Equal(intent.Id, result.Value.Id); Assert.Equal(intent.Id, result.Value.Id);
@@ -37,7 +39,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio);
await SeedAsync(owner, intent); await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
} }
@@ -45,7 +47,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
[Fact] [Fact]
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
{ {
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
} }
@@ -57,7 +59,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id); var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, stranger, intent); await SeedAsync(owner, stranger, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
} }
@@ -68,7 +70,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
await SeedAsync(owner, intent); await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
} }
@@ -79,7 +81,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
await SeedAsync(owner, intent); await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
} }
@@ -94,7 +96,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id); var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent); await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
} }
@@ -112,7 +114,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
var intent = TestEntities.UploadIntent(owner.Id); var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent); await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
} }
@@ -122,7 +124,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
{ {
await SeedAsync(owner); await SeedAsync(owner);
var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.False(result.IsError); Assert.False(result.IsError);
} }
@@ -134,7 +136,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
config.MaxActiveUploadIntentsPerUser = 1; config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
} }
@@ -149,7 +151,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired), TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed)); TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.False(result.IsError); Assert.False(result.IsError);
} }
@@ -24,11 +24,9 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
return new CreateTrackCommandHandler( return new CreateTrackCommandHandler(
Database, Database,
eventBus, eventBus,
new UploadIntentValidator(Database, storageService), new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateTrackCommandHandler>(), NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Storage(), TestConfigurations.Track());
TestConfigurations.Track(),
TestConfigurations.UploadIntent());
} }
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
@@ -1,51 +0,0 @@
using ErrorOr;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
{
private GetTrackCoverQueryHandler CreateHandler() =>
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("medium", "medium.webp")]
[InlineData("large", "large.webp")]
[InlineData("unknown-size-falls-back-to-medium", "medium.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
Assert.Equal("image/webp", result.Value.ContentType);
}
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
@@ -21,14 +21,19 @@ public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>()) .GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload"); .Returns("https://storage.musify.test/presigned-upload");
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
return new RequestTrackUploadUrlsCommandHandler( return new RequestTrackUploadUrlsCommandHandler(
Database, new UploadIntentIssuer(
new UploadIntentValidator(Database, storageService), Database,
storageService, storageService,
NoOpLogger<RequestTrackUploadUrlsCommandHandler>(), new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Storage(), TestConfigurations.Storage(),
TestConfigurations.Track(), TestConfigurations.Track(),
uploadIntentConfig ?? TestConfigurations.UploadIntent()); config);
} }
[Fact] [Fact]
+13 -24
View File
@@ -752,17 +752,6 @@ export interface components {
updatedAt: string; updatedAt: string;
coverTrackIds: string[]; coverTrackIds: string[];
}; };
AlbumPictureUploadResponse: {
/** Format: uuid */
intentId: string;
bucket: string;
key: string;
pictureName: string;
contentType: string;
/** Format: int32 */
expiresInSeconds: number | string;
uploadUrl: string;
};
AvailableGenreResponse: { AvailableGenreResponse: {
genre: components["schemas"]["Genre"]; genre: components["schemas"]["Genre"];
incompatibleWith: components["schemas"]["Genre"][]; incompatibleWith: components["schemas"]["Genre"][];
@@ -889,6 +878,17 @@ export interface components {
hasNextPage: boolean; hasNextPage: boolean;
hasPreviousPage: boolean; hasPreviousPage: boolean;
}; };
PictureUploadResponse: {
/** Format: uuid */
intentId: string;
bucket: string;
key: string;
pictureName: string;
contentType: string;
/** Format: int32 */
expiresInSeconds: number | string;
uploadUrl: string;
};
PlaybackPublicConfigResponse: { PlaybackPublicConfigResponse: {
allowAnonymousListening: boolean; allowAnonymousListening: boolean;
/** Format: int32 */ /** Format: int32 */
@@ -914,17 +914,6 @@ export interface components {
/** Format: double */ /** Format: double */
durationSeconds: number | string; durationSeconds: number | string;
}; };
PlayListPictureUploadResponse: {
/** Format: uuid */
intentId: string;
bucket: string;
key: string;
pictureName: string;
contentType: string;
/** Format: int32 */
expiresInSeconds: number | string;
uploadUrl: string;
};
/** @enum {unknown} */ /** @enum {unknown} */
PlayListVisibility: "Private" | "Public"; PlayListVisibility: "Private" | "Public";
/** @enum {unknown} */ /** @enum {unknown} */
@@ -1301,7 +1290,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["AlbumPictureUploadResponse"]; "application/json": components["schemas"]["PictureUploadResponse"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -2041,7 +2030,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PlayListPictureUploadResponse"]; "application/json": components["schemas"]["PictureUploadResponse"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */