diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index dc627dc..57635be 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -3,13 +3,8 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; -using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Events; -using Musify.Application.Pictures; -using Musify.Application.Services; using Musify.Application.Shared; -using Musify.Domain.ValueObjects; namespace Musify.Application.Albums { @@ -18,16 +13,12 @@ namespace Musify.Application.Albums Guid AlbumId, string Title, string? Description, - int? ReleaseYear, - Guid? NewPictureIntentId) + int? ReleaseYear) : ICommand>; public class UpdateAlbumCommandHandler( - IEventBus eventBus, IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - AlbumConfiguration albumConfiguration) + ILogger logger) : ICommandHandler> { public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) @@ -39,27 +30,12 @@ namespace Musify.Application.Albums var album = found.Value; var title = request.Title.Trim(); + // This is a full replace of the plain fields; the picture has its own command. album.Title = title; album.NormalizedTitle = TextNormalizer.Normalize(title); - album.Description = request.Description; + album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(); album.ReleaseYear = request.ReleaseYear; - if (request.NewPictureIntentId is { } intentId) - { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; - - var picture = prepared.Value; - album.Pictures = picture.Pictures; - - await eventBus.PublishAsync( - new UpdateAlbumPictureSourceEvent( - album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); - } - await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Updated album {AlbumId}", album.Id); diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbumPicture.cs b/backend/Core/Musify.Application/Albums/UpdateAlbumPicture.cs new file mode 100644 index 0000000..4e03faa --- /dev/null +++ b/backend/Core/Musify.Application/Albums/UpdateAlbumPicture.cs @@ -0,0 +1,60 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Albums.Responses; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Pictures; +using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums +{ + /// Replaces an album's picture with a previously uploaded image; the plain fields are untouched. + public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId) + : ICommand>; + + public class UpdateAlbumPictureCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + AlbumConfiguration albumConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(UpdateAlbumPictureCommand request, CancellationToken cancellationToken) + { + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + + var album = found.Value; + + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + var picture = prepared.Value; + album.Pictures = picture.Pictures; + + await eventBus.PublishAsync( + new UpdateAlbumPictureSourceEvent( + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated picture of album {AlbumId}", album.Id); + + return await database.Albums + .AsNoTracking() + .Where(a => a.Id == album.Id) + .SelectResponse() + .SingleAsync(cancellationToken); + } + } +} diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index f96eb11..69a93ad 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -11,7 +11,7 @@ namespace Musify.Application.Genres { public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) { - var all = Enum.GetValues(); + var all = Enum.GetValues().Where(genre => genre != Genre.None).ToArray(); IReadOnlyList result = all .Select(genre => new AvailableGenreResponse( diff --git a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs index 88ee148..b8e1138 100644 --- a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs @@ -7,6 +7,7 @@ using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists { @@ -34,7 +35,7 @@ namespace Musify.Application.PlayLists var albumTrackIds = await database.AlbumHasTracks .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .OrderBy(albumTrack => albumTrack.TrackNumber) .Select(albumTrack => albumTrack.TrackId) .ToListAsync(cancellationToken); @@ -54,8 +55,9 @@ namespace Musify.Application.PlayLists return new AddAlbumToPlayListResponse(0); // Playlist positions are internal indexes, so they start at 0. - var nextPosition = (await playListTracksQuery - .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; + var nextPosition = existingTrackIds.Count > 0 + ? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1 + : 0; foreach (var trackId in newTrackIds) { diff --git a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs index 87db300..1951026 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs @@ -47,9 +47,12 @@ namespace Musify.Application.PlayLists return Result.Success; // Playlist positions are internal indexes, so they start at 0. - var nextPosition = (await database.PlayListHasTracks - .Where(link => link.PlayListId == request.PlayListId) - .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; + var playListTracksQuery = database.PlayListHasTracks + .Where(link => link.PlayListId == request.PlayListId); + + var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken) + ? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1 + : 0; foreach (var trackId in toAdd) { diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index fec544d..a7de0a2 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -39,10 +39,10 @@ namespace Musify.Application.PlayLists return AppErrors.NotFound("User", request.UserId); PreparedPicture? picture = null; - if (request.PictureIntentId is { } intentId) + if (request.PictureIntentId.HasValue) { var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + uploadIntentValidator, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); if (prepared.IsError) return prepared.Errors; diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 6ae2c03..a69df77 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -2,12 +2,8 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Events; -using Musify.Application.Pictures; using Musify.Application.PlayLists.Responses; -using Musify.Application.Services; using Musify.Application.Shared; using Musify.Domain.ValueObjects; @@ -16,18 +12,14 @@ namespace Musify.Application.PlayLists public record UpdatePlayListCommand( long UserId, Guid PlayListId, - string? NewName, + string NewName, string? NewDescription, - Guid? NewPictureIntentId, - PlayListVisibility? NewVisibility = null) + PlayListVisibility NewVisibility) : ICommand>; public class UpdatePlayListCommandHandler( - IEventBus eventBus, IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - PlayListConfiguration playListConfiguration) + ILogger logger) : ICommandHandler> { public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) @@ -38,34 +30,11 @@ namespace Musify.Application.PlayLists var playList = found.Value; - if (!string.IsNullOrWhiteSpace(request.NewName)) - { - playList.Name = request.NewName.Trim(); - playList.NormalizedName = TextNormalizer.Normalize(request.NewName); - } - - // null means "unchanged"; a blank description clears it. - if (request.NewDescription != null) - playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); - - if (request.NewVisibility is { } visibility) - playList.Visibility = visibility; - - if (request.NewPictureIntentId is { } intentId) - { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; - - var picture = prepared.Value; - playList.Pictures = picture.Pictures; - - await eventBus.PublishAsync( - new UpdatePlayListPictureSourceEvent( - playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); - } + // This is a full replace of the plain fields; the picture has its own command. + playList.Name = request.NewName.Trim(); + playList.NormalizedName = TextNormalizer.Normalize(request.NewName); + playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); + playList.Visibility = request.NewVisibility; await database.SaveChangesAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayListPicture.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayListPicture.cs new file mode 100644 index 0000000..273acf2 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayListPicture.cs @@ -0,0 +1,60 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Pictures; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists +{ + /// Replaces a playlist's picture with a previously uploaded image; the plain fields are untouched. + public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId) + : ICommand>; + + public class UpdatePlayListPictureCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + PlayListConfiguration playListConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(UpdatePlayListPictureCommand request, CancellationToken cancellationToken) + { + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + + var playList = found.Value; + + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + var picture = prepared.Value; + playList.Pictures = picture.Pictures; + + await eventBus.PublishAsync( + new UpdatePlayListPictureSourceEvent( + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated picture of playlist {PlayListId}", playList.Id); + + return await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == playList.Id) + .SelectResponse() + .SingleAsync(cancellationToken); + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index ad734de..21fc7d4 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -90,7 +90,6 @@ namespace Musify.Application.Tracks await database.Tracks.AddAsync(trackEntity, cancellationToken); - var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); await eventBus.PublishAsync( new CreateTrackResourcesEvent( diff --git a/backend/Core/Musify.Domain/ValueObjects/Genre.cs b/backend/Core/Musify.Domain/ValueObjects/Genre.cs index dc9eeb9..f8e78dc 100644 --- a/backend/Core/Musify.Domain/ValueObjects/Genre.cs +++ b/backend/Core/Musify.Domain/ValueObjects/Genre.cs @@ -2,31 +2,33 @@ namespace Musify.Domain.ValueObjects { public enum Genre { - Pop = 0, - Rock = 1, - HipHop = 2, - RnB = 3, - Jazz = 4, - Blues = 5, - Classical = 6, - Electronic = 7, - House = 8, - Techno = 9, - Trance = 10, - Dubstep = 11, - DrumAndBass = 12, - Metal = 13, - Punk = 14, - Reggae = 15, - Reggaeton = 16, - Country = 17, - Folk = 18, - Indie = 19, - KPop = 20, - Latin = 21, - Soul = 22, - Funk = 23, - Ambient = 24, - Lofi = 25 + /// Unset value (the enum default); it is not a real genre and is never stored or offered to clients. + None = 0, + Pop = 1, + Rock = 2, + HipHop = 3, + RnB = 4, + Jazz = 5, + Blues = 6, + Classical = 7, + Electronic = 8, + House = 9, + Techno = 10, + Trance = 11, + Dubstep = 12, + DrumAndBass = 13, + Metal = 14, + Punk = 15, + Reggae = 16, + Reggaeton = 17, + Country = 18, + Folk = 19, + Indie = 20, + KPop = 21, + Latin = 22, + Soul = 23, + Funk = 24, + Ambient = 25, + Lofi = 26 } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924154218_GenreNone.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924154218_GenreNone.Designer.cs new file mode 100644 index 0000000..d78d63e --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924154218_GenreNone.Designer.cs @@ -0,0 +1,1084 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924154218_GenreNone")] + partial class GenreNone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "none", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany("PlayLists") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924154218_GenreNone.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924154218_GenreNone.cs new file mode 100644 index 0000000..83674e5 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924154218_GenreNone.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class GenreNone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,none,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance") + .OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance") + .OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,none,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 84961db..2208575 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -21,7 +21,7 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 63); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "none", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumPictureRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumPictureRequest.cs new file mode 100644 index 0000000..49cb357 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumPictureRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects.Albums +{ + public record UpdateAlbumPictureRequest(Guid PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs index 1d6d03d..cebbfac 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -3,6 +3,5 @@ namespace Musify.Api.DataTransferObjects.Albums public record UpdateAlbumRequest( string Title, string? Description, - int? ReleaseYear, - Guid? PictureIntentId); + int? ReleaseYear); } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListPictureRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListPictureRequest.cs new file mode 100644 index 0000000..8f5a8b2 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListPictureRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects.PlayLists +{ + public record UpdatePlayListPictureRequest(Guid PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index a49124c..2da0ab6 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -3,8 +3,7 @@ using Musify.Domain.ValueObjects; namespace Musify.Api.DataTransferObjects.PlayLists { public record UpdatePlayListRequest( - string? Name, + string Name, string? Description, - Guid? PictureIntentId, - PlayListVisibility? Visibility = null); + PlayListVisibility? Visibility); } diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 9fdd920..275f238 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -94,6 +94,17 @@ namespace Musify.Api.Endpoints .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); + group.MapPut("/{id:guid}/picture", UpdateAlbumPicture) + .WithName("UpdateAlbumPicture") + .WithSummary("Replace an album picture with an uploaded image") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}", DeleteAlbum) .WithName("DeleteAlbum") .WithSummary("Delete an album") @@ -229,7 +240,21 @@ namespace Musify.Api.Endpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task UpdateAlbumPicture( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdateAlbumPictureRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdateAlbumPictureCommand(currentUser.RequiredId, id, request.PictureIntentId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 0976c59..59a8623 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -68,6 +68,17 @@ namespace Musify.Api.Endpoints .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); + group.MapPut("/{id:guid}/picture", UpdatePlayListPicture) + .WithName("UpdatePlayListPicture") + .WithSummary("Replace a playlist picture with an uploaded image") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}", DeletePlayList) .WithName("DeletePlayList") .WithSummary("Delete a playlist") @@ -185,7 +196,21 @@ namespace Musify.Api.Endpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility), + new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.Visibility!.Value), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task UpdatePlayListPicture( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdatePlayListPictureRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdatePlayListPictureCommand(currentUser.RequiredId, id, request.PictureIntentId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Models/GenreParameter.cs b/backend/Hosts/Musify.Api/Models/GenreParameter.cs index e866882..d3dfa22 100644 --- a/backend/Hosts/Musify.Api/Models/GenreParameter.cs +++ b/backend/Hosts/Musify.Api/Models/GenreParameter.cs @@ -7,7 +7,7 @@ namespace Musify.Api.Models { public static bool TryParse(string? value, out GenreParameter result) { - if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + if (Enum.TryParse(value, ignoreCase: true, out var genre) && genre != Genre.None && Enum.IsDefined(genre)) { result = new GenreParameter(genre); return true; diff --git a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumPictureRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumPictureRequestValidator.cs new file mode 100644 index 0000000..bfc65d4 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumPictureRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Albums; + +namespace Musify.Api.Validators.Albums +{ + public sealed class UpdateAlbumPictureRequestValidator : AbstractValidator + { + public UpdateAlbumPictureRequestValidator() + { + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + } + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListPictureRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListPictureRequestValidator.cs new file mode 100644 index 0000000..969c6df --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListPictureRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.PlayLists; + +namespace Musify.Api.Validators.PlayLists +{ + public sealed class UpdatePlayListPictureRequestValidator : AbstractValidator + { + public UpdatePlayListPictureRequestValidator() + { + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + } + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index fd1116a..e6d25cb 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -9,16 +9,14 @@ namespace Musify.Api.Validators.PlayLists { RuleFor(x => x.Name) .NotEmpty() - .MaximumLength(Limits.PlayListName) - .When(x => x.Name != null); + .MaximumLength(Limits.PlayListName); RuleFor(x => x.Description) - .MaximumLength(Limits.Description) - .When(x => x.Description != null); + .MaximumLength(Limits.Description); RuleFor(x => x.Visibility) - .IsInEnum() - .When(x => x.Visibility != null); + .NotNull() + .IsInEnum(); } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs index f9d9876..2cbad2e 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -22,6 +22,11 @@ namespace Musify.Api.Validators.Tracks .NotEmpty() .WithMessage("At least one tag is required."); + RuleFor(x => x.Tags) + .Must(tags => !tags.Contains(Genre.None)) + .When(x => x.Tags is { Count: > 0 }) + .WithMessage("'None' is not a valid tag."); + RuleFor(x => x.Tags) .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) .When(x => x.Tags is { Count: > 0 }) diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index ae9389e..937936e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -111,6 +111,37 @@ namespace Musify.Api.Tests.Endpoints Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } + [Fact] + public async Task UpdateAlbumPicture_NotTheOwner_ReturnsForbidden() + { + var owner = await CreateUserAsync(); + var stranger = await CreateUserAsync(); + 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(TestContext.Current.CancellationToken); + + var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( + $"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task UpdateAlbumPicture_EmptyIntent_ReturnsBadRequest() + { + var owner = await CreateUserAsync(); + 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(TestContext.Current.CancellationToken); + + var response = await ownerClient.PutAsJsonAsync( + $"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.Empty), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() { diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index f5fed4f..4cf19de 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -63,7 +63,7 @@ namespace Musify.Api.Tests.Endpoints Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Equal(Enum.GetValues().Length, body!.Count); + Assert.Equal(Enum.GetValues().Count(genre => genre != Genre.None), body!.Count); Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs index d876e01..c5bfd2e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs @@ -96,7 +96,7 @@ namespace Musify.Api.Tests.Endpoints var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( - $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, PlayListVisibility.Public), Ct); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -120,13 +120,50 @@ namespace Musify.Api.Tests.Endpoints var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( - $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, PlayListVisibility.Private), Ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); Assert.Equal("Renamed", body!.Name); } + [Fact] + public async Task UpdatePlayList_MissingVisibility_ReturnsBadRequest() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task UpdatePlayListPicture_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( + $"/playlists/{playList.Id}/picture", new UpdatePlayListPictureRequest(Guid.NewGuid()), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task UpdatePlayListPicture_EmptyIntent_ReturnsBadRequest() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + + var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( + $"/playlists/{playList.Id}/picture", new UpdatePlayListPictureRequest(Guid.Empty), Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] public async Task GetPlayListById_NotAGuid_ReturnsNotFound() { diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index feb5986..9f5d7c1 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -1,24 +1,15 @@ using ErrorOr; using Musify.Application.Albums; -using Musify.Application.Contracts; -using Musify.Application.Services; using Musify.Application.Tests.TestSupport; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums { public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - private UpdateAlbumCommandHandler CreateHandler() => new( - eventBus, Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.Album()); + NoOpLogger()); [Fact] public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() @@ -27,7 +18,7 @@ namespace Musify.Application.Tests.Albums var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); await SeedAsync(owner, album); - var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); + var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -42,32 +33,22 @@ namespace Musify.Application.Tests.Albums } [Fact] - public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() + public async Task Handle_BlankDescription_ClearsIt() { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); - var owner = TestEntities.User(); - var album = TestEntities.Album(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 album = TestEntities.Album(owner.Id, title: "Title", description: "Old"); + await SeedAsync(owner, album); - var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Title", " ", null), TestContext.Current.CancellationToken); Assert.False(result.IsError); - var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + Assert.Null(result.Value.Description); } [Fact] public async Task Handle_AlbumMissing_ReturnsNotFound() { - var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); + var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -82,7 +63,7 @@ namespace Musify.Application.Tests.Albums var album = TestEntities.Album(owner.Id); await SeedAsync(owner, stranger, album); - var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); + var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumPictureCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumPictureCommandHandlerTests.cs new file mode 100644 index 0000000..c22cf08 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumPictureCommandHandlerTests.cs @@ -0,0 +1,69 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class UpdateAlbumPictureCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdateAlbumPictureCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Album()); + + [Fact] + public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, title: "Keep Title", description: "Keep description", releaseYear: 2020); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, album, intent); + + var result = await CreateHandler().Handle(new UpdateAlbumPictureCommand(owner.Id, album.Id, intent.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("Keep Title", result.Value.Title); + Assert.Equal("Keep description", result.Value.Description); + Assert.Equal(2020, result.Value.ReleaseYear); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new UpdateAlbumPictureCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); + + var result = await CreateHandler().Handle(new UpdateAlbumPictureCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index 3b1473c..a1fe6c5 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -11,11 +11,11 @@ namespace Musify.Application.Tests.Genres await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); [Fact] - public async Task Handle_ReturnsEveryGenre() + public async Task Handle_ReturnsEveryGenreExceptNone() { var result = await RunAsync(); - Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); + Assert.Equal(Enum.GetValues().Where(genre => genre != Genre.None), result.Select(r => r.Genre)); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index f7951bf..2a05c83 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -1,24 +1,15 @@ using ErrorOr; -using Musify.Application.Contracts; using Musify.Application.PlayLists; -using Musify.Application.Services; using Musify.Application.Tests.TestSupport; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.PlayLists { public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - private UpdatePlayListCommandHandler CreateHandler() => new( - eventBus, Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.PlayList()); + NoOpLogger()); [Fact] public async Task Handle_Owner_RenamesPlayList() @@ -27,7 +18,7 @@ namespace Musify.Application.Tests.PlayLists var playList = TestEntities.PlayList(owner.Id, "Old Name"); await SeedAsync(owner, playList); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Private); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -38,7 +29,7 @@ namespace Musify.Application.Tests.PlayLists [Fact] public async Task Handle_PlayListMissing_ReturnsNotFound() { - var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null); + var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Private); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -53,7 +44,7 @@ namespace Musify.Application.Tests.PlayLists var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, stranger, playList); - var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); + var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, Musify.Domain.ValueObjects.PlayListVisibility.Private); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -67,7 +58,7 @@ namespace Musify.Application.Tests.PlayLists var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, playList); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "Same Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Public); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -76,59 +67,35 @@ namespace Musify.Application.Tests.PlayLists } [Fact] - public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() + public async Task Handle_ReplacesNameDescriptionAndVisibility() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description", visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); await SeedAsync(owner, playList); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, " New Name ", "New description", Musify.Domain.ValueObjects.PlayListVisibility.Private); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); + Assert.Equal("New Name", result.Value.Name); + Assert.Equal("New description", result.Value.Description); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility); } [Fact] - public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() + public async Task Handle_MissingDescription_ClearsIt() { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); await SeedAsync(owner, playList); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "Original Name", null, Musify.Domain.ValueObjects.PlayListVisibility.Private); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal("Original Name", result.Value.Name); Assert.Null(result.Value.Description); } - - [Fact] - public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); - - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - playList.Pictures = null; - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp"); - await SeedAsync(owner, playList, intent); - - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Null(result.Value.SmallImageKeyName); - var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListPictureCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListPictureCommandHandlerTests.cs new file mode 100644 index 0000000..b1c8561 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListPictureCommandHandlerTests.cs @@ -0,0 +1,69 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.PlayLists; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.PlayLists +{ + public sealed class UpdatePlayListPictureCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdatePlayListPictureCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.PlayList()); + + [Fact] + public async Task Handle_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, "Keep Name", "Keep description"); + playList.Pictures = null; + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, playList, intent); + + var result = await CreateHandler().Handle(new UpdatePlayListPictureCommand(owner.Id, playList.Id, intent.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("Keep Name", result.Value.Name); + Assert.Equal("Keep description", result.Value.Description); + var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new UpdatePlayListPictureCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); + + var result = await CreateHandler().Handle(new UpdatePlayListPictureCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + } +} diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 11cc2e4..6e9cd5a 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -75,6 +75,23 @@ export interface paths { patch?: never; trace?: never; }; + "/albums/{id}/picture": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace an album picture with an uploaded image */ + put: operations["UpdateAlbumPicture"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums/{id}/tracks": { parameters: { query?: never; @@ -282,6 +299,23 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists/{id}/picture": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace a playlist picture with an uploaded image */ + put: operations["UpdatePlayListPicture"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists/users/{userId}": { parameters: { query?: never; @@ -958,20 +992,24 @@ export interface components { /** Format: int32 */ expiresInSeconds: number | string; }; + UpdateAlbumPictureRequest: { + /** Format: uuid */ + pictureIntentId: string; + }; UpdateAlbumRequest: { title: string; description: null | string; /** Format: int32 */ releaseYear: null | number | string; + }; + UpdatePlayListPictureRequest: { /** Format: uuid */ - pictureIntentId: null | string; + pictureIntentId: string; }; UpdatePlayListRequest: { - name: null | string; + name: string; description: null | string; - /** Format: uuid */ - pictureIntentId: null | string; - visibility?: null | components["schemas"]["PlayListVisibility"]; + visibility: null | components["schemas"]["PlayListVisibility"]; }; UserApplicationResponse: { id: string; @@ -1172,6 +1210,62 @@ export interface operations { }; }; }; + UpdateAlbumPicture: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAlbumPictureRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumApplicationResponse"]; + }; + }; + /** @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 Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; DeleteAlbum: { parameters: { query?: never; @@ -1761,6 +1855,62 @@ export interface operations { }; }; }; + UpdatePlayListPicture: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdatePlayListPictureRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlayListApplicationResponse"]; + }; + }; + /** @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 Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; DeletePlayList: { parameters: { query?: never; diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index 959dbeb..c8d9199 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -71,21 +71,34 @@ export const actions: Actions = { const parsed = parseAlbumForm(form); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); - const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover')); - if ('failMessage' in cover) { - return fail(502, { message: cover.failMessage, detail: cover.detail }); - } - const result = await api.PUT('/albums/{id}', { params: { path: { id: params.id } }, body: { title: parsed.body.title, description: parsed.body.description, - releaseYear: parsed.body.releaseYear, - pictureIntentId: cover.intentId + releaseYear: parsed.body.releaseYear } }); - return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true }; + const failure = failOnError(result, 'No se pudo actualizar el álbum.'); + if (failure) return failure; + + const coverFile = formFile(form, 'cover'); + if (coverFile) { + const cover = await uploadOptionalCover(api, '/albums/upload-picture', coverFile); + if ('failMessage' in cover) { + return fail(502, { message: cover.failMessage, detail: cover.detail }); + } + if (cover.intentId) { + const pictureResult = await api.PUT('/albums/{id}/picture', { + params: { path: { id: params.id } }, + body: { pictureIntentId: cover.intentId } + }); + const pictureFailure = failOnError(pictureResult, 'No se pudo actualizar la portada.'); + if (pictureFailure) return pictureFailure; + } + } + + return { edited: true }; }), delete: authedAction(async ({ api, params }) => { diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 37ffc2c..9616f00 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -52,21 +52,29 @@ export const actions: Actions = { if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); const { name, description, visibility, cover } = parsed.body; - const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover); - if ('failMessage' in uploaded) { - return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); - } - const result = await api.PUT('/playlists/{id}', { params: { path: { id: params.id } }, - body: { - name, - description, - pictureIntentId: uploaded.intentId, - visibility - } + body: { name, description, visibility } }); - return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true }; + const failure = failOnError(result, 'No se pudo actualizar la playlist.'); + if (failure) return failure; + + if (cover) { + const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover); + if ('failMessage' in uploaded) { + return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); + } + if (uploaded.intentId) { + const pictureResult = await api.PUT('/playlists/{id}/picture', { + params: { path: { id: params.id } }, + body: { pictureIntentId: uploaded.intentId } + }); + const pictureFailure = failOnError(pictureResult, 'No se pudo actualizar la portada.'); + if (pictureFailure) return pictureFailure; + } + } + + return { edited: true }; }), delete: authedAction(async ({ api, params }) => {