Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
63 changed files with 3118 additions and 383 deletions
Showing only changes of commit 478b90d75c - Show all commits
@@ -2,9 +2,14 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
@@ -12,12 +17,18 @@ namespace Musify.Application.Albums
long UserId,
string Title,
string? Description,
int? ReleaseYear)
int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
ILogger<CreateAlbumCommandHandler> logger)
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
@@ -31,6 +42,12 @@ namespace Musify.Application.Albums
return Error.NotFound(description: $"User {request.UserId} not found");
}
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
if (pictureIntent.IsError)
return pictureIntent.Errors;
var intent = pictureIntent.Value;
var title = request.Title.Trim();
var album = new UserAlbum
@@ -39,11 +56,23 @@ namespace Musify.Application.Albums
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description,
ReleaseYear = request.ReleaseYear
ReleaseYear = request.ReleaseYear,
Pictures = new AlbumPictures
{
OriginalName = intent.ObjectName,
SmallName = intent.ObjectName,
MediumName = intent.ObjectName,
LargeName = intent.ObjectName
}
};
await database.UserAlbums.AddAsync(album, cancellationToken);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
try
{
await database.SaveChangesAsync(cancellationToken);
@@ -58,5 +87,42 @@ namespace Musify.Application.Albums
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
}
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
}
}
}
}
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == request.AlbumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures is null)
return Error.NotFound();
var routes = albumConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -0,0 +1,105 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record RequestAlbumPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
{
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.AlbumPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
return new AlbumPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
}
@@ -0,0 +1,11 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -2,7 +2,13 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
@@ -12,12 +18,18 @@ namespace Musify.Application.Albums
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear)
int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
ILogger<UpdateAlbumCommandHandler> logger)
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
@@ -43,6 +55,38 @@ namespace Musify.Application.Albums
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
album.Pictures = new AlbumPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
database.UserAlbums.Update(album);
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
@@ -64,7 +108,45 @@ namespace Musify.Application.Albums
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
}
}
}
}
@@ -16,6 +16,7 @@ public static class ApplicationDependencyInjection
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
@@ -0,0 +1,79 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration
{
public class AlbumConfiguration
{
public const string SectionName = "Album";
[Required]
public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
[Required]
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
}
public class AlbumRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "Albums";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class AlbumPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -0,0 +1,4 @@
namespace Musify.Application.Events
{
public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Events
{
public record AlbumProcessingFailed(
Guid AlbumId,
string? Bucket,
string? PictureKey);
}
@@ -0,0 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record CreateAlbumResourcesEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record UpdateAlbumPictureEvent(
Guid AlbumId,
string Bucket,
string SourceKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events
{
public record UpdateAlbumPictureSourceEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,13 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Domain.ValueObjects
{
public class AlbumPictures
{
public string? OriginalName { get; set; }
[Required]
public required string OriginalName { get; set; }
public string? SmallName { get; set; }
[Required]
public required string SmallName { get; set; }
public string? MediumName { get; set; }
[Required]
public required string MediumName { get; set; }
public string? LargeName { get; set; }
[Required]
public required string LargeName { get; set; }
}
}
@@ -5,5 +5,6 @@ namespace Musify.Domain.ValueObjects
PlayListPicture,
TrackPicture,
TrackAudio,
AlbumPicture,
}
}
@@ -0,0 +1,32 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Albums
{
internal class MarkAlbumAsFailedActivity(
IDatabase database,
ILogger<MarkAlbumAsFailedActivity> logger)
: IExecuteActivity<MarkAlbumAsFailedArguments>
{
public const string ExecuteEndpointName = "mark-album-as-failed";
public async Task<ExecutionResult> Execute(ExecuteContext<MarkAlbumAsFailedArguments> executeContext)
{
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken);
if (album is not null)
{
album.LifeCycleStatus = LifeCycleStatus.Failed;
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Marked album {AlbumId} as failed", executeContext.Arguments.AlbumId);
}
return executeContext.Completed();
}
}
}
@@ -0,0 +1,34 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities.Albums
{
internal class PublishAlbumPictureProcessingEventActivity(
IPublishEndpoint publishEndpoint,
ILogger<PublishAlbumPictureProcessingEventActivity> logger)
: IExecuteActivity<PublishAlbumPictureProcessingEventArguments>
{
public const string ExecuteEndpointName = "publish-album-picture-processing-event";
public async Task<ExecutionResult> Execute(ExecuteContext<PublishAlbumPictureProcessingEventArguments> executeContext)
{
var arguments = executeContext.Arguments;
await publishEndpoint.Publish(
new UpdateAlbumPictureEvent(
arguments.AlbumId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Small,
arguments.Medium,
arguments.Large),
executeContext.CancellationToken);
logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId);
return executeContext.Completed();
}
}
}
@@ -0,0 +1,113 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Pictures
{
internal class UpdateAlbumPictureActivity(
IDatabase database,
IPublishEndpoint publishEndpoint,
ILogger<UpdateAlbumPictureActivity> logger)
: IActivity<UpdateAlbumPictureArguments, UpdateAlbumPictureLog>
{
public const string ExecuteEndpointName = "update-album-picture";
public async Task<ExecutionResult> Execute(ExecuteContext<UpdateAlbumPictureArguments> executeContext)
{
var smallResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.SmallPictureVariable);
ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable));
var mediumResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.MediumPictureVariable);
ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable));
var largeResizedVariable = executeContext.GetVariable<string>(executeContext.Arguments.LargePictureVariable);
ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable));
try
{
var album = await database.Albums.FindAsync(
[executeContext.Arguments.AlbumId],
cancellationToken: executeContext.CancellationToken);
if (album is null)
{
logger.LogWarning("Album {AlbumId} not found",
executeContext.Arguments.AlbumId);
throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} not found");
}
if (album.Pictures is null)
{
logger.LogWarning("Album {AlbumId} has no pending picture upload",
executeContext.Arguments.AlbumId);
throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} has no pending picture upload");
}
var log = new UpdateAlbumPictureLog(
album.Id,
album.Pictures.OriginalName,
album.Pictures.SmallName,
album.Pictures.MediumName,
album.Pictures.LargeName);
album.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey);
album.Pictures.SmallName = Path.GetFileName(smallResizedVariable);
album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable);
album.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
database.Albums.Update(album);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated album {AlbumId} pictures",
executeContext.Arguments.AlbumId);
await publishEndpoint.Publish(
new AlbumPictureProcessed(executeContext.Arguments.AlbumId),
executeContext.CancellationToken);
return executeContext.Completed();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update album {AlbumId} pictures",
executeContext.Arguments.AlbumId);
throw;
}
}
public async Task<CompensationResult> Compensate(CompensateContext<UpdateAlbumPictureLog> compensateContext)
{
try
{
var album = await database.Albums.FindAsync(
[compensateContext.Log.AlbumId],
cancellationToken: compensateContext.CancellationToken);
if (album is null)
{
return compensateContext.Compensated();
}
album.Pictures = new AlbumPictures
{
OriginalName = compensateContext.Log.PreviousOriginalPictureKey,
SmallName = compensateContext.Log.PreviousSmallPictureKey,
MediumName = compensateContext.Log.PreviousMediumPictureKey,
LargeName = compensateContext.Log.PreviousLargePictureKey
};
database.Albums.Update(album);
await database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate album {AlbumId} pictures", compensateContext.Log.AlbumId);
return compensateContext.Failed(exception);
}
}
}
}
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record MarkAlbumAsFailedArguments(Guid AlbumId);
}
@@ -0,0 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record PublishAlbumPictureProcessingEventArguments(
Guid AlbumId,
string Bucket,
string FinalPictureKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -0,0 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Arguments
{
public record UpdateAlbumPictureArguments(
Guid AlbumId,
string OriginalPictureKey,
string SmallPictureVariable,
string MediumPictureVariable,
string LargePictureVariable);
}
@@ -0,0 +1,36 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.Albums;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.RoutingSlip;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer<AlbumProcessingFailed>
{
public async Task Consume(ConsumeContext<AlbumProcessingFailed> context)
{
var message = context.Message;
var builder = new RoutingSlipBuilder(NewId.NextGuid());
builder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey))
builder.AddActivity(
ActivityNames.RemoveAlbumOriginalPicture,
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey));
builder.AddActivity(
ActivityNames.MarkAlbumAsFailed,
EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsFailedActivity.ExecuteEndpointName),
new MarkAlbumAsFailedArguments(message.AlbumId));
await bus.Execute(builder.Build());
}
}
}
@@ -0,0 +1,22 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class CreateAlbumConsumer(
IBus bus,
AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<CreateAlbumResourcesEvent>
{
public async Task Consume(ConsumeContext<CreateAlbumResourcesEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,22 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class UpdateAlbumPictureConsumer(
IBus bus,
PictureWorkflowRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureEvent>
{
public async Task Consume(ConsumeContext<UpdateAlbumPictureEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -0,0 +1,22 @@
using MassTransit;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Consumers
{
public class UpdateAlbumPictureSourceConsumer(
IBus bus,
AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder)
: IConsumer<UpdateAlbumPictureSourceEvent>
{
public async Task Consume(ConsumeContext<UpdateAlbumPictureSourceEvent> consumeContext)
{
var routingSlip = routingSlipBuilder
.Build(consumeContext.Message, consumeContext.CorrelationId)
.Build();
await bus.Execute(routingSlip);
}
}
}
@@ -1,6 +1,7 @@
using MassTransit;
using Microsoft.Extensions.DependencyInjection;
using Musify.Infrastructure.MassTransit.Activities.Pictures;
using Musify.Infrastructure.MassTransit.Activities.Albums;
using Musify.Infrastructure.MassTransit.Activities.Audio;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.PlayLists;
@@ -23,6 +24,7 @@ namespace Musify.Infrastructure.MassTransit
services.AddScoped<DeletePlayListRoutingSlipBuilder>();
services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
services.AddScoped<AlbumPictureSourceRoutingSlipBuilder>();
services.AddScoped<YouTubeTrackRoutingSlipBuilder>();
}
@@ -41,6 +43,10 @@ namespace Musify.Infrastructure.MassTransit
options.AddConsumer<TrackProcessingFailedConsumer>();
options.AddConsumer<PlayListProcessingFailedConsumer>();
options.AddConsumer<DownloadYouTubeTrackConsumer>();
options.AddConsumer<CreateAlbumConsumer>();
options.AddConsumer<UpdateAlbumPictureSourceConsumer>();
options.AddConsumer<UpdateAlbumPictureConsumer>();
options.AddConsumer<AlbumProcessingFailedConsumer>();
options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>();
options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>();
@@ -53,9 +59,12 @@ namespace Musify.Infrastructure.MassTransit
options.AddExecuteActivity<PublishPlayListPictureProcessingEventActivity, PublishPlayListPictureProcessingEventArguments>();
options.AddExecuteActivity<MarkTrackAsFailedActivity, MarkTrackAsFailedArguments>();
options.AddExecuteActivity<MarkPlayListAsFailedActivity, MarkPlayListAsFailedArguments>();
options.AddExecuteActivity<MarkAlbumAsFailedActivity, MarkAlbumAsFailedArguments>();
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
options.AddActivity<UpdatePlayListPictureActivity, UpdatePlayListPictureArguments, UpdatePlayListPictureLog>();
options.AddActivity<UpdateAlbumPictureActivity, UpdateAlbumPictureArguments, UpdateAlbumPictureLog>();
options.AddActivity<UpdateTrackPictureActivity, UpdateTrackPictureArguments, UpdateTrackPictureLog>();
options.AddActivity<DownloadFileFromBucketActivity, DownloadFileFromBucketArguments, DownloadFileFromBucketLog>();
options.AddActivity<TranscodeAudioActivity, TranscodeAudioArguments, TranscodeAudioLog>();
@@ -83,6 +83,14 @@ namespace Musify.Infrastructure.MassTransit
repository.UsePostgres();
} );
options.AddSagaStateMachine<AlbumProcessingStateMachine, AlbumProcessingState>()
.EntityFrameworkRepository(repository =>
{
repository.ConcurrencyMode = ConcurrencyMode.Pessimistic;
repository.ExistingDbContext<Database>();
repository.UsePostgres();
} );
options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) =>
{
UseStandardRetry(endpointConfigurator);
@@ -0,0 +1,9 @@
namespace Musify.Infrastructure.MassTransit.Logs
{
public record UpdateAlbumPictureLog(
Guid AlbumId,
string PreviousOriginalPictureKey,
string PreviousSmallPictureKey,
string PreviousMediumPictureKey,
string PreviousLargePictureKey);
}
@@ -8,6 +8,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving";
internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed";
internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed";
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
@@ -30,6 +31,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string UpdateTrackPicture = "UpdateTrackPicture";
internal const string UpdatePlayListPicture = "UpdatePlayListPicture";
internal const string UpdateAlbumPicture = "UpdateAlbumPicture";
internal const string UpdateTrackAudio = "UpdateTrackAudio";
internal const string CopyPictureToFinal = "CopyPictureToFinal";
@@ -39,6 +41,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents";
internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent";
internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent";
internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture";
internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture";
@@ -51,5 +54,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture";
internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture";
internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture";
internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture";
}
}
@@ -0,0 +1,85 @@
using MassTransit;
using MassTransit.Courier.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Infrastructure.MassTransit.Activities.Albums;
using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.UploadIntents;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Consumers;
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public class AlbumPictureSourceRoutingSlipBuilder
{
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId)
=> BuildSlip(
message.AlbumId,
message.PictureIntentId,
message.Bucket,
message.PictureSourceKey,
message.PictureDestinationKey,
message.Small,
message.Medium,
message.Large,
correlationId);
public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId)
=> BuildSlip(
message.AlbumId,
message.PictureIntentId,
message.Bucket,
message.PictureSourceKey,
message.PictureDestinationKey,
message.Small,
message.Medium,
message.Large,
correlationId);
static RoutingSlipBuilder BuildSlip(
Guid albumId,
Guid pictureIntentId,
string bucket,
string pictureSourceKey,
string pictureDestinationKey,
ImageSize small,
ImageSize medium,
ImageSize large,
Guid? correlationId)
{
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
routingSlipBuilder.AddActivity(
ActivityNames.CopyPictureToFinal,
EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName),
new CopyFileInBucketArguments(
bucket,
pictureSourceKey,
bucket,
pictureDestinationKey));
routingSlipBuilder.AddActivity(
ActivityNames.ConsumeUploadIntents,
EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName),
new ConsumeUploadIntentsArguments([pictureIntentId]));
routingSlipBuilder.AddActivity(
ActivityNames.PublishAlbumPictureProcessingEvent,
EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName),
new PublishAlbumPictureProcessingEventArguments(
albumId,
bucket,
pictureDestinationKey,
small,
medium,
large));
return routingSlipBuilder;
}
}
}
@@ -70,6 +70,35 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
return routingSlipBuilder;
}
public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId)
{
var routingSlipBuilder = BuildPictureWorkflow(
message.Bucket,
message.SourceKey,
message.Small,
message.Medium,
message.Large,
correlationId);
routingSlipBuilder.AddActivity(
ActivityNames.UpdateAlbumPicture,
EndpointHelper.BuildExecuteActivityUri(UpdateAlbumPictureActivity.ExecuteEndpointName),
new UpdateAlbumPictureArguments(
message.AlbumId,
message.SourceKey,
RoutingSlipVariableNames.Picture.SmallResizedFilePath,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
RoutingSlipVariableNames.Picture.LargeResizedFilePath));
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.AlbumId);
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.AlbumPicture);
routingSlipBuilder.AddSubscription(
EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName),
RoutingSlipEvents.Faulted);
return routingSlipBuilder;
}
RoutingSlipBuilder BuildPictureWorkflow(
string bucket,
string sourceKey,
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.MassTransit
internal const string TrackPicture = "TrackPicture";
internal const string TrackAudio = "TrackAudio";
internal const string PlayListPicture = "PlayListPicture";
internal const string AlbumPicture = "AlbumPicture";
}
internal static class Audio
@@ -0,0 +1,19 @@
using MassTransit;
namespace Musify.Infrastructure.MassTransit.Sagas
{
public class AlbumProcessingState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = null!;
public string? Bucket { get; set; }
public string? PictureKey { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}
@@ -0,0 +1,51 @@
using MassTransit;
using Musify.Application.Events;
namespace Musify.Infrastructure.MassTransit.Sagas
{
public sealed class AlbumProcessingStateMachine : MassTransitStateMachine<AlbumProcessingState>
{
public State Processing { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<CreateAlbumResourcesEvent> ProcessingStarted { get; private set; } = null!;
public Event<AlbumPictureProcessed> PictureProcessed { get; private set; } = null!;
public Event<AlbumPictureProcessingFailed> PictureFailed { get; private set; } = null!;
public AlbumProcessingStateMachine()
{
InstanceState(state => state.CurrentState);
Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId));
Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.AlbumId));
Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.AlbumId));
Initially(
When(ProcessingStarted)
.Then(context =>
{
context.Saga.CreatedAt = DateTime.UtcNow;
context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
} )
.TransitionTo(Processing));
During(Processing,
When(PictureProcessed).Finalize(),
When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
.TransitionTo(Failed)
.Publish(context => new AlbumProcessingFailed(
context.Saga.CorrelationId,
context.Saga.Bucket,
context.Saga.PictureKey)));
During(Failed,
Ignore(PictureProcessed),
Ignore(PictureFailed));
SetCompletedWhenFinalized();
}
}
}
@@ -163,6 +163,11 @@ namespace Musify.Infrastructure.Persistence
playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
playListProcessing.Property(state => state.CurrentState).HasMaxLength(64);
var albumProcessing = modelBuilder.Entity<AlbumProcessingState>();
albumProcessing.HasKey(state => state.CorrelationId);
albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
albumProcessing.Property(state => state.CurrentState).HasMaxLength(64);
}
public DbSet<User> Users => Set<User>();
@@ -0,0 +1,38 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAlbumProcessingStateSaga : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AlbumProcessingState",
columns: table => new
{
CorrelationId = table.Column<Guid>(type: "uuid", nullable: false),
CurrentState = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Bucket = table.Column<string>(type: "text", nullable: true),
PictureKey = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AlbumProcessingState", x => x.CorrelationId);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AlbumProcessingState");
}
}
}
@@ -657,6 +657,33 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b =>
{
b.Property<Guid>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("Bucket")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CurrentState")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("PictureKey")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("CorrelationId");
b.ToTable("AlbumProcessingState");
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
{
b.Property<Guid>("CorrelationId")
@@ -804,21 +831,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid");
b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName");
+18
View File
@@ -87,6 +87,24 @@
"LargePictureHeight": 512
}
},
"Album": {
"Routes": {
"UploadsFolder": "uploads",
"ParentFolders": "Albums",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures"
},
"PicturesSizes": {
"SmallPictureWidth": 128,
"SmallPictureHeight": 128,
"MediumPictureWidth": 256,
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
}
},
"Track": {
"Routes": {
"UploadsFolder": "uploads",
@@ -3,4 +3,5 @@ namespace Musify.Api.DataTransferObjects.Albums;
public record CreateAlbumRequest(
string Title,
string? Description,
int? ReleaseYear);
int? ReleaseYear,
Guid PictureIntentId);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.Albums;
public record RequestAlbumPictureUploadRequest(
string FileType,
string ContentType,
long? ExpectedSizeBytes = null);
@@ -3,4 +3,5 @@ namespace Musify.Api.DataTransferObjects.Albums;
public record UpdateAlbumRequest(
string NewTitle,
string? NewDescription,
int? NewReleaseYear);
int? NewReleaseYear,
Guid? NewPictureIntentId);
@@ -5,6 +5,7 @@ using Musify.Api.Extensions;
using Musify.Api.Filters;
using Musify.Application.Albums;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Application.YouTube;
@@ -55,6 +56,22 @@ public static class AlbumEndpoints
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/cover", GetAlbumCover)
.WithName("GetAlbumCover")
.WithSummary("Get An Album Cover Image.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/upload-picture", RequestAlbumPictureUpload)
.WithName("RequestAlbumPictureUpload")
.WithSummary("Request A Pre-Signed URL To Upload An Album Picture.")
.AddEndpointFilter<ValidationFilter<RequestAlbumPictureUploadRequest>>()
.RequireAuthorization()
.Produces<AlbumPictureUploadResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateAlbum)
.WithName("CreateAlbum")
.WithSummary("Create A New Album.")
@@ -169,6 +186,37 @@ public static class AlbumEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbumCover(
IMediator mediator,
IStorageService storageService,
HttpResponse response,
Guid id,
CancellationToken cancellationToken,
string size = "medium")
{
var result = await mediator.Send(new GetAlbumCoverQuery(id, size), cancellationToken);
if (result.IsError)
return Results.NotFound();
var location = result.Value;
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, location.ContentType);
}
private static async Task<IResult> RequestAlbumPictureUpload(
IMediator mediator,
CurrentUser currentUser,
RequestAlbumPictureUploadRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateAlbum(
IMediator mediator,
CurrentUser currentUser,
@@ -176,7 +224,7 @@ public static class AlbumEndpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear),
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId),
cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}");
@@ -190,7 +238,7 @@ public static class AlbumEndpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear),
new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear, request.NewPictureIntentId),
cancellationToken);
return result.ToHttpResult();
@@ -17,5 +17,8 @@ public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumR
RuleFor(x => x.ReleaseYear)
.InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest)
.When(x => x.ReleaseYear.HasValue);
RuleFor(x => x.PictureIntentId)
.NotEmpty();
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Albums;
namespace Musify.Api.Validators.Albums;
public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator<RequestAlbumPictureUploadRequest>
{
public RequestAlbumPictureUploadRequestValidator()
{
RuleFor(x => x.FileType)
.NotEmpty();
RuleFor(x => x.ContentType)
.NotEmpty();
RuleFor(x => x.ExpectedSizeBytes)
.GreaterThan(0)
.When(x => x.ExpectedSizeBytes.HasValue);
}
}
@@ -4,6 +4,8 @@ using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Endpoints
@@ -20,11 +22,30 @@ namespace Musify.Api.Tests.Endpoints
return userId;
}
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
fixture.StorageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var response = await client.PostAsJsonAsync(
"/albums/upload-picture",
new RequestAlbumPictureUploadRequest("webp", "image/webp"),
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AlbumPictureUploadResponse>(TestContext.Current.CancellationToken);
return body!.IntentId;
}
[Fact]
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient()
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken);
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
@@ -34,8 +55,9 @@ namespace Musify.Api.Tests.Endpoints
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
@@ -50,7 +72,18 @@ namespace Musify.Api.Tests.Endpoints
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
@@ -60,7 +93,8 @@ namespace Musify.Api.Tests.Endpoints
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken))
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -73,7 +107,9 @@ namespace Musify.Api.Tests.Endpoints
{
var owner = await CreateUserAsync();
var stranger = await CreateUserAsync();
var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken))
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -86,7 +122,8 @@ namespace Musify.Api.Tests.Endpoints
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken))
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -11,7 +11,7 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_ValidRequest_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024));
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid()));
Assert.True(result.IsValid);
}
@@ -19,7 +19,7 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_ValidRequestWithoutOptionalFields_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null));
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid()));
Assert.True(result.IsValid);
}
@@ -27,7 +27,7 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_BlankTitle_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("", null, null));
var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid()));
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
@@ -36,7 +36,7 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_TitleOverMaxLength_Fails()
{
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null));
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid()));
Assert.False(result.IsValid);
}
@@ -44,7 +44,7 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1));
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid()));
Assert.False(result.IsValid);
}
@@ -52,7 +52,7 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest));
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid()));
Assert.True(result.IsValid);
}
@@ -60,7 +60,7 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest));
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid()));
Assert.True(result.IsValid);
}
@@ -68,9 +68,18 @@ namespace Musify.Api.Tests.Validators
[Fact]
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1));
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid()));
Assert.False(result.IsValid);
}
[Fact]
public void Validate_EmptyPictureIntentId_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty));
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId));
}
}
}
@@ -1,21 +1,39 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
{
private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateAlbumCommandHandler>());
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private CreateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Album(),
TestConfigurations.UploadIntent());
[Fact]
public async Task Handle_UserExists_CreatesAlbumAndReturnsIt()
public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent()
{
var owner = TestEntities.User();
await SeedAsync(owner);
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024);
var owner = TestEntities.User();
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp");
await SeedAsync(owner, intent);
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -29,16 +47,31 @@ namespace Musify.Application.Tests.Albums
var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UserDoesNotExist_ReturnsNotFound()
{
var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null);
var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PictureIntentNotFound_ReturnsError()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
}
}
@@ -0,0 +1,38 @@
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.UserAlbum(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);
}
}
}
@@ -0,0 +1,75 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase
{
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
{
storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
return new RequestAlbumPictureUploadCommandHandler(
Database,
new UploadIntentValidator(Database, storageService),
storageService,
NoOpLogger<RequestAlbumPictureUploadCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Album(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
}
[Fact]
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
{
var user = TestEntities.User();
await SeedAsync(user);
var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp");
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp");
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadBytesPerUser = 100;
var user = TestEntities.User();
await SeedAsync(user);
var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
}
}
@@ -1,13 +1,26 @@
using ErrorOr;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
{
private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<UpdateAlbumCommandHandler>());
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
NoOpLogger<UpdateAlbumCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Album(),
TestConfigurations.UploadIntent());
[Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
@@ -16,7 +29,7 @@ namespace Musify.Application.Tests.Albums
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album);
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025);
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -30,10 +43,33 @@ namespace Musify.Application.Tests.Albums
Assert.Equal("NEW TITLE", stored.NormalizedTitle);
}
[Fact]
public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title");
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp");
await SeedAsync(owner, album, intent);
var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdateAlbumPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null);
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -48,7 +84,7 @@ namespace Musify.Application.Tests.Albums
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -11,6 +11,8 @@ namespace Musify.Application.Tests.TestSupport
public static PlayListConfiguration PlayList() => new();
public static AlbumConfiguration Album() => new();
public static MixConfiguration Mix() => new();
public static UploadIntentConfiguration UploadIntent() => new();
+353 -314
View File
@@ -11,7 +11,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get Paginated Albums. */
/** Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given. */
get: operations["GetAlbums"];
put?: never;
/** Create A New Album. */
@@ -39,6 +39,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/albums/external/{source}/{externalId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist. */
get: operations["GetExternalAlbum"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/users/{userId}": {
parameters: {
query?: never;
@@ -90,6 +107,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/albums/{id}/cover": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get An Album Cover Image. */
get: operations["GetAlbumCover"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/upload-picture": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Request A Pre-Signed URL To Upload An Album Picture. */
post: operations["RequestAlbumPictureUpload"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/{albumId}": {
parameters: {
query?: never;
@@ -222,7 +273,8 @@ export interface paths {
/** Get Paginated Tracks Of A PlayList. */
get: operations["GetPlayListTracks"];
put?: never;
post?: never;
/** Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed. */
post: operations["AddPlayListTrack"];
delete?: never;
options?: never;
head?: never;
@@ -290,8 +342,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Add A Track To A PlayList. */
post: operations["AddTrackToPlayList"];
post?: never;
/** Remove A Track From A PlayList. */
delete: operations["RemoveTrackFromPlayList"];
options?: never;
@@ -299,23 +350,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/playlists/{playlistId}/youtube-tracks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Add A YouTube Track To A PlayList, Downloading It In The Background If Needed. */
post: operations["AddYouTubeTrackToPlayList"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks": {
parameters: {
query?: never;
@@ -375,7 +409,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get A Streaming Manifest URL And Ticket For A Track. */
/** Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It. */
get: operations["GetTrackStream"];
put?: never;
post?: never;
@@ -385,6 +419,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/tracks/external/{source}/{externalId}/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It. */
get: operations["GetExternalTrackStream"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks/users/{userId}": {
parameters: {
query?: never;
@@ -488,63 +539,15 @@ export interface paths {
patch?: never;
trace?: never;
};
"/youtube/albums": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Search Albums On YouTube Music. */
get: operations["SearchYouTubeAlbums"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/youtube/albums/{albumId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A YouTube Music Album With Its Tracklist. */
get: operations["GetYouTubeAlbum"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/youtube/tracks/{videoId}/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. */
get: operations["GetYouTubeTrackStream"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AddYouTubeTrackRequest: {
videoId: string;
AddPlayListTrackRequest: {
/** Format: uuid */
trackId: null | string;
source: null | components["schemas"]["TrackSource"];
externalId: null | string;
};
AlbumApplicationResponse: {
/** Format: uuid */
@@ -565,14 +568,48 @@ export interface components {
/** Format: date-time */
updatedAt: string;
coverTrackIds: string[];
youTubeAlbumId: null | string;
thumbnailUrl: null | string;
youTubeAlbumId?: null | string;
thumbnailUrl?: null | string;
};
AlbumPictureUploadResponse: {
/** Format: uuid */
intentId: string;
bucket: string;
key: string;
pictureName: string;
contentType: string;
/** Format: int32 */
expiresInSeconds: number | string;
uploadUrl: string;
};
AlbumSearchItemResponse: {
source: components["schemas"]["TrackSource"];
album: null | components["schemas"]["AlbumApplicationResponse"];
youTubeAlbum: null | components["schemas"]["YouTubeAlbumResult"];
};
AlbumsSearchResponse: {
items: components["schemas"]["AlbumSearchItemResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasPreviousPage: boolean;
hasNextLocalPage: boolean;
nextYoutubeContinuationToken: null | string;
youtubeUnavailable: boolean;
hasNextPage: boolean;
};
CreateAlbumRequest: {
title: string;
description: null | string;
/** Format: int32 */
releaseYear: null | number | string;
/** Format: uuid */
pictureIntentId: string;
};
CreatePlayListRequest: {
name: string;
@@ -622,7 +659,7 @@ export interface components {
title: string;
artist: null | string;
thumbnailUrl: null | string;
/** Format: int32 */
/** Format: double */
durationSeconds: number | string;
isExplicit: boolean;
};
@@ -707,6 +744,12 @@ export interface components {
};
/** @enum {unknown} */
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
RequestAlbumPictureUploadRequest: {
fileType: string;
contentType: string;
/** Format: int64 */
expectedSizeBytes?: null | number | string;
};
RequestPlayListPictureUploadRequest: {
fileType: string;
contentType: string;
@@ -731,7 +774,7 @@ export interface components {
source: components["schemas"]["TrackSource"];
externalId: null | string;
audioStatus: components["schemas"]["ProcessingStatus"];
/** Format: int32 */
/** Format: double */
duration: number | string;
/** Format: int32 */
listensCount: number | string;
@@ -744,11 +787,27 @@ export interface components {
};
TrackSearchItemResponse: {
source: components["schemas"]["TrackSource"];
track: components["schemas"]["TrackApplicationResponse"] | null;
youTubeSong: components["schemas"]["YouTubeSongResult"] | null;
track: null | components["schemas"]["TrackApplicationResponse"];
youTubeSong: null | components["schemas"]["YouTubeSongResult"];
};
/** @enum {unknown} */
TrackSource: "Local" | "YouTube";
TracksSearchResponse: {
items: components["schemas"]["TrackSearchItemResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasPreviousPage: boolean;
hasNextLocalPage: boolean;
nextYoutubeContinuationToken: null | string;
youtubeUnavailable: boolean;
hasNextPage: boolean;
};
TrackStreamResponse: {
manifestUrl: string;
ticket: string;
@@ -772,27 +831,13 @@ export interface components {
/** Format: int32 */
expiresInSeconds: number | string;
};
TracksSearchResponse: {
items: components["schemas"]["TrackSearchItemResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasPreviousPage: boolean;
hasNextLocalPage: boolean;
nextYoutubeContinuationToken: null | string;
youtubeUnavailable: boolean;
hasNextPage: boolean;
};
UpdateAlbumRequest: {
newTitle: string;
newDescription: null | string;
/** Format: int32 */
newReleaseYear: null | number | string;
/** Format: uuid */
newPictureIntentId: null | string;
};
UpdatePlayListRequest: {
newName: null | string;
@@ -830,10 +875,6 @@ export interface components {
isEp: boolean;
artists: components["schemas"]["YouTubeArtistRef"][];
};
YouTubeAlbumSearchResult: {
items: components["schemas"]["YouTubeAlbumResult"][];
continuationToken: string;
};
YouTubeAlbumTrack: {
videoId: string;
title: string;
@@ -861,13 +902,13 @@ export interface components {
/** @enum {unknown} */
YouTubeStreamMode: "Server" | "YouTube";
YouTubeStreamResponse: {
/** Format: uuid */
trackId: null | string;
mode: components["schemas"]["YouTubeStreamMode"];
streamUrl: string;
ticket: string;
ticket: null | string;
/** Format: int32 */
expiresInSeconds: number | string;
/** Format: uuid */
trackId: string;
};
};
responses: never;
@@ -884,6 +925,7 @@ export interface operations {
title?: string;
pageNumber?: number | string;
pageSize?: number | string;
youtubeContinuationToken?: string;
};
header?: never;
path?: never;
@@ -897,7 +939,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"];
"application/json": components["schemas"]["AlbumsSearchResponse"];
};
};
};
@@ -978,6 +1020,36 @@ export interface operations {
};
};
};
GetExternalAlbum: {
parameters: {
query?: never;
header?: never;
path: {
source: string;
externalId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumDetail"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetAlbumsByUserId: {
parameters: {
query?: {
@@ -1071,6 +1143,82 @@ export interface operations {
};
};
};
GetAlbumCover: {
parameters: {
query?: {
size?: string;
};
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RequestAlbumPictureUpload: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RequestAlbumPictureUploadRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlbumPictureUploadResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UpdateAlbum: {
parameters: {
query?: never;
@@ -1451,6 +1599,62 @@ export interface operations {
};
};
};
AddPlayListTrack: {
parameters: {
query?: never;
header?: never;
path: {
playlistId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AddPlayListTrackRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackApplicationResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetPlayListCover: {
parameters: {
query?: {
@@ -1610,48 +1814,6 @@ export interface operations {
};
};
};
AddTrackToPlayList: {
parameters: {
query?: never;
header?: never;
path: {
playlistId: string;
trackId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RemoveTrackFromPlayList: {
parameters: {
query?: never;
@@ -1687,62 +1849,6 @@ export interface operations {
};
};
};
AddYouTubeTrackToPlayList: {
parameters: {
query?: never;
header?: never;
path: {
playlistId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AddYouTubeTrackRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackApplicationResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetTracks: {
parameters: {
query?: {
@@ -1916,6 +2022,50 @@ export interface operations {
};
};
};
GetExternalTrackStream: {
parameters: {
query?: never;
header?: never;
path: {
source: string;
externalId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeStreamResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetTracksByUserId: {
parameters: {
query?: {
@@ -2159,115 +2309,4 @@ export interface operations {
};
};
};
SearchYouTubeAlbums: {
parameters: {
query: {
query: string;
continuation?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumSearchResult"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetYouTubeAlbum: {
parameters: {
query?: never;
header?: never;
path: {
albumId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeAlbumDetail"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetYouTubeTrackStream: {
parameters: {
query?: never;
header?: never;
path: {
videoId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["YouTubeStreamResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
@@ -14,6 +15,8 @@
initialDescription?: string;
initialReleaseYear?: number;
titlePlaceholder?: string;
coverFallbackUrl?: string;
requireCover?: boolean;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
@@ -27,6 +30,8 @@
initialDescription = '',
initialReleaseYear,
titlePlaceholder = '',
coverFallbackUrl,
requireCover = false,
formMessage,
submitLabel,
submittingLabel,
@@ -45,6 +50,7 @@
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
@@ -55,6 +61,12 @@
}}
class="mt-4 w-full max-w-2xl space-y-4"
>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field label="Portada" optional={!requireCover}>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Título" for="album-title">
<Input
id="album-title"
@@ -77,6 +89,8 @@
placeholder={String(new Date().getFullYear())}
/>
</Field>
</div>
</div>
<Field label="Descripción" for="album-description" optional>
<Textarea
@@ -4,12 +4,14 @@
interface Props {
name: string;
fallbackUrl?: string;
required?: boolean;
class?: string;
}
let {
name,
fallbackUrl,
required = false,
class:
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props();
@@ -30,6 +32,7 @@
type="file"
{name}
accept="image/*"
{required}
onchange={onInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
@@ -3,26 +3,39 @@
interface Props {
playlistId?: string;
coverUrl?: string;
trackIds: (string | number)[];
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let { playlistId, trackIds, size = 'medium', version, class: klass = '' }: Props = $props();
let {
playlistId,
coverUrl,
trackIds,
size = 'medium',
version,
class: klass = ''
}: Props = $props();
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
const src = $derived(
coverUrl
? coverUrl
: playlistId
? `/api/playlists/${playlistId}/cover?size=${size}${version ? `&v=${encodeURIComponent(version)}` : ''}`
: undefined
);
</script>
{#if playlistId && !coverFailed}
{#if src && !coverFailed}
<div class="relative overflow-hidden bg-surface {klass}">
<img
src="/api/playlists/{playlistId}/cover?size={size}{version
? `&v=${encodeURIComponent(version)}`
: ''}"
{src}
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
+1 -1
View File
@@ -4,7 +4,7 @@ const ALLOWED_SIZES = new Set(['small', 'medium', 'large']);
export async function proxyCoverImage(
fetch: typeof globalThis.fetch,
kind: 'playlists' | 'tracks',
kind: 'playlists' | 'tracks' | 'albums',
id: string,
size: string | null
): Promise<Response> {
@@ -8,6 +8,7 @@ import {
} from '$lib/server/api';
import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
@@ -27,11 +28,28 @@ export const actions: Actions = {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const parsed = parseAlbumForm(await request.formData());
const form = await request.formData();
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const cover = form.get('cover');
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'La portada es obligatoria.' });
}
const api = createApiClient({ fetch, accessToken });
const { data, error: err } = await api.POST('/albums', { body: parsed.body });
const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }),
cover
);
if ('failMessage' in uploaded) {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
}
const { data, error: err } = await api.POST('/albums', {
body: { ...parsed.body, pictureIntentId: uploaded.intentId }
});
if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
@@ -43,6 +43,7 @@
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={album.coverTrackIds}
coverSrc="/api/albums/{album.id}/cover?size=large"
index={i}
/>
{/each}
@@ -67,6 +68,7 @@
<AlbumForm
action="?/create"
titlePlaceholder="Mi primer disco"
requireCover
formMessage={form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
@@ -9,6 +9,7 @@ import {
} from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
@@ -80,16 +81,32 @@ export const actions: Actions = {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const parsed = parseAlbumForm(await request.formData());
const form = await request.formData();
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
const cover = form.get('cover');
if (cover instanceof File && cover.size > 0) {
const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }),
cover
);
if ('failMessage' in uploaded) {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
}
newPictureIntentId = uploaded.intentId;
}
const result = await api.PUT('/albums/{albumId}', {
params: { path: { albumId: params.id } },
body: {
newTitle: parsed.body.title,
newDescription: parsed.body.description,
newReleaseYear: parsed.body.releaseYear
newReleaseYear: parsed.body.releaseYear,
newPictureIntentId
}
});
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
@@ -41,6 +41,7 @@
description={album.description ?? undefined}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackIds={tracks.map((track) => track.id)}
coverUrl="/api/albums/{album.id}/cover?size=large"
playing={isCurrentQueue && player.playing}
{isOwner}
onPlayAll={playAll}
@@ -67,6 +68,7 @@
initialTitle={album.title}
initialDescription={album.description ?? ''}
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
formMessage={form?.message}
submitLabel="Guardar"
submittingLabel="Guardando…"
@@ -12,6 +12,7 @@
description?: string;
releaseYear?: number;
trackIds: (string | number)[];
coverUrl?: string;
playing: boolean;
isOwner: boolean;
onPlayAll: () => void;
@@ -24,6 +25,7 @@
description,
releaseYear,
trackIds,
coverUrl,
playing,
isOwner,
onPlayAll,
@@ -49,6 +51,7 @@
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<PlaylistArt
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
/>
@@ -0,0 +1,6 @@
import type { RequestHandler } from './$types';
import { proxyCoverImage } from '$lib/server/image';
export const GET: RequestHandler = async ({ params, url, fetch }) => {
return proxyCoverImage(fetch, 'albums', params.id, url.searchParams.get('size'));
};