A lot of changes

This commit is contained in:
2026-09-24 17:54:16 +02:00
parent ece6ce2a43
commit e84c16cf92
35 changed files with 1815 additions and 222 deletions
@@ -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<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
ILogger<UpdateAlbumCommandHandler> logger)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> 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);
@@ -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
{
/// <summary>Replaces an album's picture with a previously uploaded image; the plain fields are untouched.</summary>
public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumPictureCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<UpdateAlbumPictureCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> 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);
}
}
}
@@ -11,7 +11,7 @@ namespace Musify.Application.Genres
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
var all = Enum.GetValues<Genre>().Where(genre => genre != Genre.None).ToArray();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
@@ -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)
{
@@ -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)
{
@@ -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;
@@ -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<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
ILogger<UpdatePlayListCommandHandler> logger)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> 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);
@@ -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
{
/// <summary>Replaces a playlist's picture with a previously uploaded image; the plain fields are untouched.</summary>
public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListPictureCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListPictureCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListPictureCommand, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> 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);
}
}
}
@@ -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(
@@ -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
/// <summary>Unset value (the enum default); it is not a real genre and is never stored or offered to clients.</summary>
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
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class GenreNone : Migration
{
/// <inheritdoc />
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");
}
/// <inheritdoc />
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");
}
}
}
@@ -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 =>