Deduplicate picture flows: shared upload issuer, cover query and picture source change
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -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);
|
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new CreateTrackResourcesEvent(
|
new CreateTrackResourcesEvent(
|
||||||
trackEntity.Id,
|
trackEntity.Id,
|
||||||
pictureIntent.Id,
|
pictureIntent.Id,
|
||||||
audioIntent.Id,
|
audioIntent.Id,
|
||||||
storageConfiguration.Bucket,
|
pictureIntent.Bucket,
|
||||||
pictureIntent.Key,
|
pictureIntent.Key,
|
||||||
finalPictureKey,
|
finalPictureKey,
|
||||||
audioIntent.Key,
|
audioIntent.Key,
|
||||||
finalAudioKey,
|
finalAudioKey,
|
||||||
audioProcessedFolderKey,
|
audioProcessedFolderKey,
|
||||||
sizes.Small,
|
sizes),
|
||||||
sizes.Medium,
|
|
||||||
sizes.Large),
|
|
||||||
cancellationToken);
|
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");
|
],
|
||||||
}
|
|
||||||
|
|
||||||
var effectivePictureSize = request.ExpectedPictureSizeBytes
|
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
|
||||||
var effectiveAudioSize = request.ExpectedAudioSizeBytes
|
|
||||||
?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes;
|
|
||||||
|
|
||||||
var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}";
|
|
||||||
var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}";
|
|
||||||
|
|
||||||
var tempPictureKey = trackConfiguration.Routes.BuildTempPath(
|
|
||||||
uploadIntentConfiguration.TempRootPrefix, request.UserId, pictureObjectName);
|
|
||||||
var tempAudioKey = trackConfiguration.Routes.BuildTempPath(
|
|
||||||
uploadIntentConfiguration.TempRootPrefix, request.UserId, audioObjectName);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
|
||||||
|
|
||||||
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempPictureKey,
|
|
||||||
request.PictureContentType,
|
|
||||||
expiresIn,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
if (issued.IsError)
|
||||||
|
return issued.Errors;
|
||||||
|
|
||||||
var audioUploadUrl = await storageService.GetUploadUrlAsync(
|
var picture = issued.Value[0];
|
||||||
storageConfiguration.Bucket,
|
var audio = issued.Value[1];
|
||||||
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(
|
return new TrackUploadUrlsResponse(
|
||||||
pictureIntent.Id,
|
picture.IntentId,
|
||||||
audioIntent.Id,
|
audio.IntentId,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
tempPictureKey,
|
picture.Key,
|
||||||
pictureObjectName,
|
picture.ObjectName,
|
||||||
request.PictureContentType,
|
picture.ContentType,
|
||||||
pictureUploadUrl,
|
picture.UploadUrl,
|
||||||
tempAudioKey,
|
audio.Key,
|
||||||
audioObjectName,
|
audio.ObjectName,
|
||||||
request.AudioContentType,
|
audio.ContentType,
|
||||||
audioUploadUrl,
|
audio.UploadUrl,
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -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);
|
||||||
|
|||||||
+1
-3
@@ -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);
|
||||||
|
|||||||
+1
-3
@@ -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(
|
||||||
|
|||||||
+1
-3
@@ -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);
|
|
||||||
|
|||||||
+1
-3
@@ -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);
|
|
||||||
|
|||||||
+1
-3
@@ -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);
|
|
||||||
|
|||||||
+4
-12
@@ -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);
|
||||||
|
|||||||
+1
-3
@@ -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);
|
||||||
|
|||||||
+7
-15
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-12
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-4
@@ -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(
|
||||||
|
new UploadIntentIssuer(
|
||||||
Database,
|
Database,
|
||||||
new UploadIntentValidator(Database, storageService),
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-4
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-4
@@ -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(
|
||||||
|
new UploadIntentIssuer(
|
||||||
Database,
|
Database,
|
||||||
new UploadIntentValidator(Database, storageService),
|
|
||||||
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]
|
||||||
|
|||||||
+2
-4
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-3
@@ -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(
|
||||||
|
new UploadIntentIssuer(
|
||||||
Database,
|
Database,
|
||||||
new UploadIntentValidator(Database, storageService),
|
|
||||||
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]
|
||||||
|
|||||||
Vendored
+13
-24
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user