Make album cover pictures mandatory, unify cover handling with playlists

Albums previously had no cover concept at all: no picture upload
flow, no GET /albums/{id}/cover, and AlbumApplicationResponse's
image-key fields were plumbed but never populated. Description,
Pictures and TrackCount already lived on the shared Album base entity
for both local and external albums, so unifying the cover story only
required building the missing local-album picture pipeline.

Backend, cloning PlayList's existing async picture-processing
architecture exactly (MassTransit routing slip + saga), but making
the picture mandatory instead of optional (matching Track's pattern):

- AlbumPictures moves from all-nullable to required fields.
- New AlbumConfiguration (routes/sizes), deliberately without
  PlayList's preset-picture fallback since a picture is now required
  up front.
- CreateAlbum now takes a mandatory PictureIntentId, validates and
  consumes the upload intent, and kicks off the same
  copy-to-final -> resize -> upload -> update-entity routing slip
  PlayLists use, via a new AlbumProcessingState saga that marks the
  album Failed on a picture-processing failure.
- UpdateAlbum takes an optional NewPictureIntentId to replace the
  cover later.
- New GET /albums/{id}/cover and POST /albums/upload-picture
  endpoints, mirroring the playlist ones.
- New EF migration for the AlbumProcessingState saga table.

Frontend:

- AlbumForm gains a cover dropzone, required on create and optional
  on edit (via ImageDropzone's new `required` prop), uploaded through
  the same presigned-upload helper playlists use.
- New /api/albums/{id}/cover proxy route.
- PlaylistArt gains a generic coverUrl prop so AlbumHeader and the
  album list/detail cards render the real uploaded cover instead of
  only the track mosaic fallback.

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