feat: rework the home layout

Recently played keeps its shape. Mixes and playlists now share one row,
half and half on large screens with smaller cards, capped at six each
with a link to the full list. Albums are the ones the user listened to
most recently, which is a new query joining the listening history with
the album tracks. The endless track wall is now "Descubrir".
This commit is contained in:
2026-07-21 19:06:36 +02:00
parent 775b5a7a8a
commit 699b36c966
6 changed files with 180 additions and 42 deletions
@@ -34,6 +34,13 @@ public static class AlbumEndpoints
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/recent", GetRecentlyListenedAlbums)
.WithName("GetRecentlyListenedAlbums")
.WithSummary("Get The Albums The Current User Listened To Most Recently.")
.RequireAuthorization()
.Produces<IReadOnlyList<AlbumApplicationResponse>>()
.Produces(StatusCodes.Status401Unauthorized);
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
.WithName("GetAlbumTracks")
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
@@ -119,6 +126,16 @@ public static class AlbumEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> GetRecentlyListenedAlbums(
IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken,
int limit = 12)
{
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAlbumTracks(
IMediator mediator,
Guid albumId,
@@ -0,0 +1,61 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{
var listenedTrackIds = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Join(
listenedTrackIds,
albumTrack => albumTrack.TrackId,
history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt)
.Take(request.Limit)
.ToListAsync(cancellationToken);
if (ranked.Count == 0)
return new List<AlbumApplicationResponse>();
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albums = await database.UserAlbums
.AsNoTracking()
.Where(album => albumIds.Contains(album.Id))
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
return ranked
.Where(entry => albums.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.ToList();
}
}
}