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