Merge branch 'frontend-refactor-rev' into audit-merge-three

This commit is contained in:
2026-09-24 17:25:31 +02:00
182 changed files with 5259 additions and 2944 deletions
@@ -0,0 +1,86 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
public class AddAlbumToPlayListCommandHandler(
IDatabase database,
ILogger<AddAlbumToPlayListCommandHandler> logger)
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
{
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var albumExists = await database.Albums
.AsNoTracking()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound(description: $"Album {request.AlbumId} not found");
}
var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var existingTrackIds = (await playListTracksQuery
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken)).ToHashSet();
var newTrackIds = albumTrackIds
.Where(trackId => !existingTrackIds.Contains(trackId))
.ToList();
if (newTrackIds.Count == 0)
return new AddAlbumToPlayListResponse(0);
var nextPosition = existingTrackIds.Count > 0
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddRangeAsync(
newTrackIds.Select((trackId, index) => new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition + index
}),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
newTrackIds.Count, request.AlbumId, request.PlayListId);
return new AddAlbumToPlayListResponse(newTrackIds.Count);
}
}
}
@@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
}
}
}
@@ -25,6 +25,8 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -0,0 +1,4 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
@@ -10,7 +12,10 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
PlaylistVisibility Visibility,
int TrackCount,
double DurationSeconds,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
@@ -19,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses
public static PlayListApplicationResponse FromEntity(
PlayList playList,
int trackCount = 0,
double durationSeconds = 0,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
@@ -28,7 +35,10 @@ namespace Musify.Application.PlayLists.Responses
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.UserId,
playList.Visibility,
trackCount,
durationSeconds,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
@@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
var totals = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.GroupBy(plt => plt.PlayListId)
.Select(group => new
{
TrackCount = group.Count(),
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
})
.SingleOrDefaultAsync(cancellationToken);
return PlayListApplicationResponse.FromEntity(
playListEntity,
totals?.TrackCount ?? 0,
totals?.DurationSeconds ?? 0,
coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
@@ -94,6 +94,14 @@ public static class PlayListEndpoints
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList)
.WithName("AddAlbumToPlayList")
.WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.")
.RequireAuthorization()
.Produces<AddAlbumToPlayListResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
.WithName("RemoveTrackFromPlayList")
.WithSummary("Remove A Track From A PlayList.")
@@ -234,6 +242,20 @@ public static class PlayListEndpoints
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
}
private static async Task<IResult> AddAlbumToPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
Guid albumId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveTrackFromPlayList(
IMediator mediator,
CurrentUser currentUser,
@@ -0,0 +1,91 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.PlayLists
{
public sealed class AddAlbumToPlayListCommandHandlerTests : HandlerTestBase
{
private AddAlbumToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>());
[Fact]
public async Task Handle_AddsAlbumTracksInAlbumOrder()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var album = TestEntities.Album(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
await SeedAsync(
owner, playList, album, first, second,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.AddedCount);
var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([first.Id, second.Id], links.Select(link => link.TrackId));
Assert.Equal([0, 1], links.Select(link => link.Position));
}
[Fact]
public async Task Handle_SkipsTracksAlreadyInPlayList_AndAppendsAfterThem()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var album = TestEntities.Album(owner.Id);
var existing = TestEntities.Track(owner, "Existing");
var fresh = TestEntities.Track(owner, "Fresh");
await SeedAsync(
owner, playList, album, existing, fresh,
new AlbumHasTrack { AlbumId = album.Id, TrackId = existing.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = fresh.Id, TrackNumber = 2 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 4 });
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(1, result.Value.AddedCount);
var link = await Database.PlayListHasTracks.SingleAsync(l => l.TrackId == fresh.Id, TestContext.Current.CancellationToken);
Assert.Equal(5, link.Position);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, playList, album);
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}