Design Prot Merge #1
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -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);
|
||||||
|
}
|
||||||
+12
@@ -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);
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Infrastructure.MassTransit.Arguments
|
||||||
|
{
|
||||||
|
public record UpdateAlbumPictureArguments(
|
||||||
|
Guid AlbumId,
|
||||||
|
string OriginalPictureKey,
|
||||||
|
string SmallPictureVariable,
|
||||||
|
string MediumPictureVariable,
|
||||||
|
string LargePictureVariable);
|
||||||
|
}
|
||||||
+36
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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>();
|
||||||
|
|||||||
+8
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+85
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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>();
|
||||||
|
|||||||
+1240
File diff suppressed because it is too large
Load Diff
+38
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+6
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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();
|
||||||
|
|||||||
Vendored
+353
-314
@@ -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)}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user