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 Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Services;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -12,12 +17,18 @@ namespace Musify.Application.Albums
long UserId, long UserId,
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear) int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler( public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database, IDatabase database,
ILogger<CreateAlbumCommandHandler> logger) UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
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)
@@ -31,6 +42,12 @@ namespace Musify.Application.Albums
return Error.NotFound(description: $"User {request.UserId} not found"); 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 title = request.Title.Trim();
var album = new UserAlbum var album = new UserAlbum
@@ -39,11 +56,23 @@ namespace Musify.Application.Albums
Title = title, Title = title,
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description, 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); 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 try
{ {
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
@@ -58,5 +87,42 @@ namespace Musify.Application.Albums
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
{
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 Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums
@@ -12,12 +18,18 @@ namespace Musify.Application.Albums
Guid AlbumId, Guid AlbumId,
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear) int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler( public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database, IDatabase database,
ILogger<UpdateAlbumCommandHandler> logger) UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
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)
@@ -43,6 +55,38 @@ namespace Musify.Application.Albums
album.Description = request.Description; album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear; 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 try
{ {
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
@@ -64,7 +108,45 @@ namespace Musify.Application.Albums
.Select(albumTrack => albumTrack.TrackId) .Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds); 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<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName); services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName); services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName); services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.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 namespace Musify.Domain.ValueObjects
{ {
public class AlbumPictures 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, PlayListPicture,
TrackPicture, TrackPicture,
TrackAudio, 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 MassTransit;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.Pictures;
using Musify.Infrastructure.MassTransit.Activities.Albums;
using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Audio;
using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.Files;
using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.PlayLists;
@@ -23,6 +24,7 @@ namespace Musify.Infrastructure.MassTransit
services.AddScoped<DeletePlayListRoutingSlipBuilder>(); services.AddScoped<DeletePlayListRoutingSlipBuilder>();
services.AddScoped<CreateTrackRoutingSlipBuilder>(); services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>(); services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
services.AddScoped<AlbumPictureSourceRoutingSlipBuilder>();
services.AddScoped<YouTubeTrackRoutingSlipBuilder>(); services.AddScoped<YouTubeTrackRoutingSlipBuilder>();
} }
@@ -41,6 +43,10 @@ namespace Musify.Infrastructure.MassTransit
options.AddConsumer<TrackProcessingFailedConsumer>(); options.AddConsumer<TrackProcessingFailedConsumer>();
options.AddConsumer<PlayListProcessingFailedConsumer>(); options.AddConsumer<PlayListProcessingFailedConsumer>();
options.AddConsumer<DownloadYouTubeTrackConsumer>(); options.AddConsumer<DownloadYouTubeTrackConsumer>();
options.AddConsumer<CreateAlbumConsumer>();
options.AddConsumer<UpdateAlbumPictureSourceConsumer>();
options.AddConsumer<UpdateAlbumPictureConsumer>();
options.AddConsumer<AlbumProcessingFailedConsumer>();
options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>(); options.AddExecuteActivity<RemoveFileFromBucketActivity, RemoveFileFromBucketArguments>();
options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>(); options.AddExecuteActivity<GenerateAudioWorkflowPathsActivity, GenerateAudioWorkflowPathsArguments>();
@@ -53,9 +59,12 @@ namespace Musify.Infrastructure.MassTransit
options.AddExecuteActivity<PublishPlayListPictureProcessingEventActivity, PublishPlayListPictureProcessingEventArguments>(); options.AddExecuteActivity<PublishPlayListPictureProcessingEventActivity, PublishPlayListPictureProcessingEventArguments>();
options.AddExecuteActivity<MarkTrackAsFailedActivity, MarkTrackAsFailedArguments>(); options.AddExecuteActivity<MarkTrackAsFailedActivity, MarkTrackAsFailedArguments>();
options.AddExecuteActivity<MarkPlayListAsFailedActivity, MarkPlayListAsFailedArguments>(); options.AddExecuteActivity<MarkPlayListAsFailedActivity, MarkPlayListAsFailedArguments>();
options.AddExecuteActivity<MarkAlbumAsFailedActivity, MarkAlbumAsFailedArguments>();
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>(); options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
options.AddActivity<UpdatePlayListPictureActivity, UpdatePlayListPictureArguments, UpdatePlayListPictureLog>(); options.AddActivity<UpdatePlayListPictureActivity, UpdatePlayListPictureArguments, UpdatePlayListPictureLog>();
options.AddActivity<UpdateAlbumPictureActivity, UpdateAlbumPictureArguments, UpdateAlbumPictureLog>();
options.AddActivity<UpdateTrackPictureActivity, UpdateTrackPictureArguments, UpdateTrackPictureLog>(); options.AddActivity<UpdateTrackPictureActivity, UpdateTrackPictureArguments, UpdateTrackPictureLog>();
options.AddActivity<DownloadFileFromBucketActivity, DownloadFileFromBucketArguments, DownloadFileFromBucketLog>(); options.AddActivity<DownloadFileFromBucketActivity, DownloadFileFromBucketArguments, DownloadFileFromBucketLog>();
options.AddActivity<TranscodeAudioActivity, TranscodeAudioArguments, TranscodeAudioLog>(); options.AddActivity<TranscodeAudioActivity, TranscodeAudioArguments, TranscodeAudioLog>();
@@ -83,6 +83,14 @@ namespace Musify.Infrastructure.MassTransit
repository.UsePostgres(); repository.UsePostgres();
} ); } );
options.AddSagaStateMachine<AlbumProcessingStateMachine, AlbumProcessingState>()
.EntityFrameworkRepository(repository =>
{
repository.ConcurrencyMode = ConcurrencyMode.Pessimistic;
repository.ExistingDbContext<Database>();
repository.UsePostgres();
} );
options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) =>
{ {
UseStandardRetry(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 MarkPlayListAsRemoving = "MarkPlayListAsRemoving";
internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed";
internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed";
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
@@ -30,6 +31,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string UpdateTrackPicture = "UpdateTrackPicture"; internal const string UpdateTrackPicture = "UpdateTrackPicture";
internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; internal const string UpdatePlayListPicture = "UpdatePlayListPicture";
internal const string UpdateAlbumPicture = "UpdateAlbumPicture";
internal const string UpdateTrackAudio = "UpdateTrackAudio"; internal const string UpdateTrackAudio = "UpdateTrackAudio";
internal const string CopyPictureToFinal = "CopyPictureToFinal"; internal const string CopyPictureToFinal = "CopyPictureToFinal";
@@ -39,6 +41,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents";
internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent";
internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent";
internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture";
internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture";
@@ -51,5 +54,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture";
internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture";
internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; 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; 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( RoutingSlipBuilder BuildPictureWorkflow(
string bucket, string bucket,
string sourceKey, string sourceKey,
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.MassTransit
internal const string TrackPicture = "TrackPicture"; internal const string TrackPicture = "TrackPicture";
internal const string TrackAudio = "TrackAudio"; internal const string TrackAudio = "TrackAudio";
internal const string PlayListPicture = "PlayListPicture"; internal const string PlayListPicture = "PlayListPicture";
internal const string AlbumPicture = "AlbumPicture";
} }
internal static class Audio 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.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); 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>(); 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"); 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 => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
{ {
b.Property<Guid>("CorrelationId") b.Property<Guid>("CorrelationId")
@@ -804,21 +831,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b1.Property<string>("LargeName") b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("LargePictureName"); .HasColumnName("LargePictureName");
b1.Property<string>("MediumName") b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName"); .HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName") b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName"); .HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName") b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName"); .HasColumnName("SmallPictureName");
+18
View File
@@ -87,6 +87,24 @@
"LargePictureHeight": 512 "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": { "Track": {
"Routes": { "Routes": {
"UploadsFolder": "uploads", "UploadsFolder": "uploads",
@@ -3,4 +3,5 @@ namespace Musify.Api.DataTransferObjects.Albums;
public record CreateAlbumRequest( public record CreateAlbumRequest(
string Title, string Title,
string? Description, 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( public record UpdateAlbumRequest(
string NewTitle, string NewTitle,
string? NewDescription, string? NewDescription,
int? NewReleaseYear); int? NewReleaseYear,
Guid? NewPictureIntentId);
@@ -5,6 +5,7 @@ using Musify.Api.Extensions;
using Musify.Api.Filters; 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.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Application.YouTube; using Musify.Application.YouTube;
@@ -55,6 +56,22 @@ public static class AlbumEndpoints
.Produces<PaginatedResponse<TrackApplicationResponse>>() .Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound); .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) group.MapPost("/", CreateAlbum)
.WithName("CreateAlbum") .WithName("CreateAlbum")
.WithSummary("Create A New Album.") .WithSummary("Create A New Album.")
@@ -169,6 +186,37 @@ public static class AlbumEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> CreateAlbum(
IMediator mediator, IMediator mediator,
CurrentUser currentUser, CurrentUser currentUser,
@@ -176,7 +224,7 @@ public static class AlbumEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( 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); cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}"); return result.ToCreatedResult(album => $"/albums/{album.Id}");
@@ -190,7 +238,7 @@ public static class AlbumEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( 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); cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -17,5 +17,8 @@ public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumR
RuleFor(x => x.ReleaseYear) RuleFor(x => x.ReleaseYear)
.InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest) .InclusiveBetween(AlbumReleaseYear.Earliest, AlbumReleaseYear.Latest)
.When(x => x.ReleaseYear.HasValue); .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.DataTransferObjects.Users;
using Musify.Api.Tests.TestSupport; using Musify.Api.Tests.TestSupport;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Endpoints namespace Musify.Api.Tests.Endpoints
@@ -20,11 +22,30 @@ namespace Musify.Api.Tests.Endpoints
return userId; 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] [Fact]
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
{ {
var response = await fixture.CreateAnonymousClient() 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); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
} }
@@ -34,8 +55,9 @@ namespace Musify.Api.Tests.Endpoints
{ {
var userId = await CreateUserAsync(); var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); 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); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
@@ -50,7 +72,18 @@ namespace Musify.Api.Tests.Endpoints
var userId = await CreateUserAsync(); var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); 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); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
} }
@@ -60,7 +93,8 @@ namespace Musify.Api.Tests.Endpoints
{ {
var userId = await CreateUserAsync(); var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); 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); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", 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 owner = await CreateUserAsync();
var stranger = 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); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", 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 userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); 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); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -11,7 +11,7 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_ValidRequest_Passes() 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); Assert.True(result.IsValid);
} }
@@ -19,7 +19,7 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_ValidRequestWithoutOptionalFields_Passes() 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); Assert.True(result.IsValid);
} }
@@ -27,7 +27,7 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_BlankTitle_Fails() 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.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
@@ -36,7 +36,7 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_TitleOverMaxLength_Fails() 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); Assert.False(result.IsValid);
} }
@@ -44,7 +44,7 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() 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); Assert.False(result.IsValid);
} }
@@ -52,7 +52,7 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() 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); Assert.True(result.IsValid);
} }
@@ -60,7 +60,7 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_ReleaseYearAtTheLatestBoundary_Passes() 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); Assert.True(result.IsValid);
} }
@@ -68,9 +68,18 @@ namespace Musify.Api.Tests.Validators
[Fact] [Fact]
public void Validate_ReleaseYearPastTheLatestBoundary_Fails() 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); 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 ErrorOr;
using Musify.Application.Albums; using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums namespace Musify.Application.Tests.Albums
{ {
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase 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] [Fact]
public async Task Handle_UserExists_CreatesAlbumAndReturnsIt() public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent()
{ {
var owner = TestEntities.User(); storageService
await SeedAsync(owner); .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); 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); var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored); Assert.NotNull(stored);
Assert.Equal("MY ALBUM", stored.NormalizedTitle); 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] [Fact]
public async Task Handle_UserDoesNotExist_ReturnsNotFound() 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); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); 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 ErrorOr;
using Musify.Application.Albums; using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums namespace Musify.Application.Tests.Albums
{ {
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase 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] [Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() 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); var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album); 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); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -30,10 +43,33 @@ namespace Musify.Application.Tests.Albums
Assert.Equal("NEW TITLE", stored.NormalizedTitle); 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] [Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound() 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); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -48,7 +84,7 @@ namespace Musify.Application.Tests.Albums
var album = TestEntities.UserAlbum(owner.Id); var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album); 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); 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 PlayListConfiguration PlayList() => new();
public static AlbumConfiguration Album() => new();
public static MixConfiguration Mix() => new(); public static MixConfiguration Mix() => new();
public static UploadIntentConfiguration UploadIntent() => new(); public static UploadIntentConfiguration UploadIntent() => new();
+353 -314
View File
@@ -11,7 +11,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Get Paginated Albums. */ /** Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given. */
get: operations["GetAlbums"]; get: operations["GetAlbums"];
put?: never; put?: never;
/** Create A New Album. */ /** Create A New Album. */
@@ -39,6 +39,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/albums/users/{userId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -90,6 +107,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/albums/{albumId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -222,7 +273,8 @@ export interface paths {
/** Get Paginated Tracks Of A PlayList. */ /** Get Paginated Tracks Of A PlayList. */
get: operations["GetPlayListTracks"]; get: operations["GetPlayListTracks"];
put?: never; 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; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -290,8 +342,7 @@ export interface paths {
}; };
get?: never; get?: never;
put?: never; put?: never;
/** Add A Track To A PlayList. */ post?: never;
post: operations["AddTrackToPlayList"];
/** Remove A Track From A PlayList. */ /** Remove A Track From A PlayList. */
delete: operations["RemoveTrackFromPlayList"]; delete: operations["RemoveTrackFromPlayList"];
options?: never; options?: never;
@@ -299,23 +350,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/tracks": {
parameters: { parameters: {
query?: never; query?: never;
@@ -375,7 +409,7 @@ export interface paths {
path?: never; path?: never;
cookie?: 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"]; get: operations["GetTrackStream"];
put?: never; put?: never;
post?: never; post?: never;
@@ -385,6 +419,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/tracks/users/{userId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -488,63 +539,15 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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 type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
AddYouTubeTrackRequest: { AddPlayListTrackRequest: {
videoId: string; /** Format: uuid */
trackId: null | string;
source: null | components["schemas"]["TrackSource"];
externalId: null | string;
}; };
AlbumApplicationResponse: { AlbumApplicationResponse: {
/** Format: uuid */ /** Format: uuid */
@@ -565,14 +568,48 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
coverTrackIds: string[]; coverTrackIds: string[];
youTubeAlbumId: null | string; youTubeAlbumId?: null | string;
thumbnailUrl: 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: { CreateAlbumRequest: {
title: string; title: string;
description: null | string; description: null | string;
/** Format: int32 */ /** Format: int32 */
releaseYear: null | number | string; releaseYear: null | number | string;
/** Format: uuid */
pictureIntentId: string;
}; };
CreatePlayListRequest: { CreatePlayListRequest: {
name: string; name: string;
@@ -622,7 +659,7 @@ export interface components {
title: string; title: string;
artist: null | string; artist: null | string;
thumbnailUrl: null | string; thumbnailUrl: null | string;
/** Format: int32 */ /** Format: double */
durationSeconds: number | string; durationSeconds: number | string;
isExplicit: boolean; isExplicit: boolean;
}; };
@@ -707,6 +744,12 @@ export interface components {
}; };
/** @enum {unknown} */ /** @enum {unknown} */
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
RequestAlbumPictureUploadRequest: {
fileType: string;
contentType: string;
/** Format: int64 */
expectedSizeBytes?: null | number | string;
};
RequestPlayListPictureUploadRequest: { RequestPlayListPictureUploadRequest: {
fileType: string; fileType: string;
contentType: string; contentType: string;
@@ -731,7 +774,7 @@ export interface components {
source: components["schemas"]["TrackSource"]; source: components["schemas"]["TrackSource"];
externalId: null | string; externalId: null | string;
audioStatus: components["schemas"]["ProcessingStatus"]; audioStatus: components["schemas"]["ProcessingStatus"];
/** Format: int32 */ /** Format: double */
duration: number | string; duration: number | string;
/** Format: int32 */ /** Format: int32 */
listensCount: number | string; listensCount: number | string;
@@ -744,11 +787,27 @@ export interface components {
}; };
TrackSearchItemResponse: { TrackSearchItemResponse: {
source: components["schemas"]["TrackSource"]; source: components["schemas"]["TrackSource"];
track: components["schemas"]["TrackApplicationResponse"] | null; track: null | components["schemas"]["TrackApplicationResponse"];
youTubeSong: components["schemas"]["YouTubeSongResult"] | null; youTubeSong: null | components["schemas"]["YouTubeSongResult"];
}; };
/** @enum {unknown} */ /** @enum {unknown} */
TrackSource: "Local" | "YouTube"; 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: { TrackStreamResponse: {
manifestUrl: string; manifestUrl: string;
ticket: string; ticket: string;
@@ -772,27 +831,13 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
expiresInSeconds: number | string; 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: { UpdateAlbumRequest: {
newTitle: string; newTitle: string;
newDescription: null | string; newDescription: null | string;
/** Format: int32 */ /** Format: int32 */
newReleaseYear: null | number | string; newReleaseYear: null | number | string;
/** Format: uuid */
newPictureIntentId: null | string;
}; };
UpdatePlayListRequest: { UpdatePlayListRequest: {
newName: null | string; newName: null | string;
@@ -830,10 +875,6 @@ export interface components {
isEp: boolean; isEp: boolean;
artists: components["schemas"]["YouTubeArtistRef"][]; artists: components["schemas"]["YouTubeArtistRef"][];
}; };
YouTubeAlbumSearchResult: {
items: components["schemas"]["YouTubeAlbumResult"][];
continuationToken: string;
};
YouTubeAlbumTrack: { YouTubeAlbumTrack: {
videoId: string; videoId: string;
title: string; title: string;
@@ -861,13 +902,13 @@ export interface components {
/** @enum {unknown} */ /** @enum {unknown} */
YouTubeStreamMode: "Server" | "YouTube"; YouTubeStreamMode: "Server" | "YouTube";
YouTubeStreamResponse: { YouTubeStreamResponse: {
/** Format: uuid */
trackId: null | string;
mode: components["schemas"]["YouTubeStreamMode"]; mode: components["schemas"]["YouTubeStreamMode"];
streamUrl: string; streamUrl: string;
ticket: string; ticket: null | string;
/** Format: int32 */ /** Format: int32 */
expiresInSeconds: number | string; expiresInSeconds: number | string;
/** Format: uuid */
trackId: string;
}; };
}; };
responses: never; responses: never;
@@ -884,6 +925,7 @@ export interface operations {
title?: string; title?: string;
pageNumber?: number | string; pageNumber?: number | string;
pageSize?: number | string; pageSize?: number | string;
youtubeContinuationToken?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -897,7 +939,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { 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: { GetAlbumsByUserId: {
parameters: { parameters: {
query?: { 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: { UpdateAlbum: {
parameters: { parameters: {
query?: never; 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: { GetPlayListCover: {
parameters: { parameters: {
query?: { 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: { RemoveTrackFromPlayList: {
parameters: { parameters: {
query?: never; 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: { GetTracks: {
parameters: { parameters: {
query?: { 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: { GetTracksByUserId: {
parameters: { parameters: {
query?: { 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"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
import Input from './Input.svelte'; import Input from './Input.svelte';
@@ -14,6 +15,8 @@
initialDescription?: string; initialDescription?: string;
initialReleaseYear?: number; initialReleaseYear?: number;
titlePlaceholder?: string; titlePlaceholder?: string;
coverFallbackUrl?: string;
requireCover?: boolean;
formMessage?: string; formMessage?: string;
submitLabel: string; submitLabel: string;
submittingLabel: string; submittingLabel: string;
@@ -27,6 +30,8 @@
initialDescription = '', initialDescription = '',
initialReleaseYear, initialReleaseYear,
titlePlaceholder = '', titlePlaceholder = '',
coverFallbackUrl,
requireCover = false,
formMessage, formMessage,
submitLabel, submitLabel,
submittingLabel, submittingLabel,
@@ -45,6 +50,7 @@
<form <form
method="POST" method="POST"
{action} {action}
enctype="multipart/form-data"
use:enhance={() => { use:enhance={() => {
submitting = true; submitting = true;
return async ({ update, result }) => { return async ({ update, result }) => {
@@ -55,6 +61,12 @@
}} }}
class="mt-4 w-full max-w-2xl space-y-4" 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"> <Field label="Título" for="album-title">
<Input <Input
id="album-title" id="album-title"
@@ -77,6 +89,8 @@
placeholder={String(new Date().getFullYear())} placeholder={String(new Date().getFullYear())}
/> />
</Field> </Field>
</div>
</div>
<Field label="Descripción" for="album-description" optional> <Field label="Descripción" for="album-description" optional>
<Textarea <Textarea
@@ -4,12 +4,14 @@
interface Props { interface Props {
name: string; name: string;
fallbackUrl?: string; fallbackUrl?: string;
required?: boolean;
class?: string; class?: string;
} }
let { let {
name, name,
fallbackUrl, fallbackUrl,
required = false,
class: class:
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50' klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props(); }: Props = $props();
@@ -30,6 +32,7 @@
type="file" type="file"
{name} {name}
accept="image/*" accept="image/*"
{required}
onchange={onInput} onchange={onInput}
class="absolute inset-0 cursor-pointer opacity-0" class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada" aria-label="Seleccionar portada"
@@ -3,26 +3,39 @@
interface Props { interface Props {
playlistId?: string; playlistId?: string;
coverUrl?: string;
trackIds: (string | number)[]; trackIds: (string | number)[];
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
version?: string; version?: string;
class?: 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); let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4); const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null); 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> </script>
{#if playlistId && !coverFailed} {#if src && !coverFailed}
<div class="relative overflow-hidden bg-surface {klass}"> <div class="relative overflow-hidden bg-surface {klass}">
<img <img
src="/api/playlists/{playlistId}/cover?size={size}{version {src}
? `&v=${encodeURIComponent(version)}`
: ''}"
alt="" alt=""
loading="lazy" loading="lazy"
onerror={() => (coverFailed = true)} onerror={() => (coverFailed = true)}
+1 -1
View File
@@ -4,7 +4,7 @@ const ALLOWED_SIZES = new Set(['small', 'medium', 'large']);
export async function proxyCoverImage( export async function proxyCoverImage(
fetch: typeof globalThis.fetch, fetch: typeof globalThis.fetch,
kind: 'playlists' | 'tracks', kind: 'playlists' | 'tracks' | 'albums',
id: string, id: string,
size: string | null size: string | null
): Promise<Response> { ): Promise<Response> {
@@ -8,6 +8,7 @@ import {
} from '$lib/server/api'; } from '$lib/server/api';
import { ALBUMS_PAGE_SIZE } from '$lib/config'; import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm'; import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
@@ -27,11 +28,28 @@ export const actions: Actions = {
const accessToken = requireAccessTokenAction(locals); const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken; 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 }); 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 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.' }); 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)} releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)} trackCount={Number(album.trackCount)}
trackIds={album.coverTrackIds} trackIds={album.coverTrackIds}
coverSrc="/api/albums/{album.id}/cover?size=large"
index={i} index={i}
/> />
{/each} {/each}
@@ -67,6 +68,7 @@
<AlbumForm <AlbumForm
action="?/create" action="?/create"
titlePlaceholder="Mi primer disco" titlePlaceholder="Mi primer disco"
requireCover
formMessage={form?.message} formMessage={form?.message}
submitLabel="Crear" submitLabel="Crear"
submittingLabel="Creando…" submittingLabel="Creando…"
@@ -9,6 +9,7 @@ import {
} from '$lib/server/api'; } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config'; import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm'; import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
@@ -80,16 +81,32 @@ export const actions: Actions = {
const accessToken = requireAccessTokenAction(locals); const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken; 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 }); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const api = createApiClient({ fetch, accessToken }); 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}', { const result = await api.PUT('/albums/{albumId}', {
params: { path: { albumId: params.id } }, params: { path: { albumId: params.id } },
body: { body: {
newTitle: parsed.body.title, newTitle: parsed.body.title,
newDescription: parsed.body.description, newDescription: parsed.body.description,
newReleaseYear: parsed.body.releaseYear newReleaseYear: parsed.body.releaseYear,
newPictureIntentId
} }
}); });
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.'); const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
@@ -41,6 +41,7 @@
description={album.description ?? undefined} description={album.description ?? undefined}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackIds={tracks.map((track) => track.id)} trackIds={tracks.map((track) => track.id)}
coverUrl="/api/albums/{album.id}/cover?size=large"
playing={isCurrentQueue && player.playing} playing={isCurrentQueue && player.playing}
{isOwner} {isOwner}
onPlayAll={playAll} onPlayAll={playAll}
@@ -67,6 +68,7 @@
initialTitle={album.title} initialTitle={album.title}
initialDescription={album.description ?? ''} initialDescription={album.description ?? ''}
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
formMessage={form?.message} formMessage={form?.message}
submitLabel="Guardar" submitLabel="Guardar"
submittingLabel="Guardando…" submittingLabel="Guardando…"
@@ -12,6 +12,7 @@
description?: string; description?: string;
releaseYear?: number; releaseYear?: number;
trackIds: (string | number)[]; trackIds: (string | number)[];
coverUrl?: string;
playing: boolean; playing: boolean;
isOwner: boolean; isOwner: boolean;
onPlayAll: () => void; onPlayAll: () => void;
@@ -24,6 +25,7 @@
description, description,
releaseYear, releaseYear,
trackIds, trackIds,
coverUrl,
playing, playing,
isOwner, isOwner,
onPlayAll, 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"> <div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<PlaylistArt <PlaylistArt
{trackIds} {trackIds}
{coverUrl}
size="large" size="large"
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" 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'));
};