diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs index 633dbb3..ee2902e 100644 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -18,23 +18,13 @@ public class AddTrackToAlbumCommandHandler( { public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.Albums - .AsNoTracking() - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - return AppErrors.NotFound("Album", request.AlbumId); + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; - if (album.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Album", request.AlbumId); - - var track = await database.Tracks - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track == null) - return AppErrors.NotFound("Track", request.TrackId); - - if (track.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Track", request.TrackId); + var track = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (track.IsError) + return track.Errors; var alreadyAdded = await database.AlbumHasTracks .AsNoTracking() diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index f2b9b9d..5c37244 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Application.Services; using Musify.Domain.Entities; @@ -53,7 +54,7 @@ public class CreateAlbumCommandHandler( { OwnerUserId = request.UserId, Title = title, - NormalizedTitle = title.ToUpperInvariant(), + NormalizedTitle = TextNormalizer.Normalize(title), Description = request.Description, ReleaseYear = request.ReleaseYear, Pictures = EntityPictures.Pending(intent.ObjectName) @@ -101,7 +102,7 @@ public class CreateAlbumCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 27787e9..bb4a96e 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -1,6 +1,5 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Events; @@ -20,20 +19,17 @@ public class DeleteAlbumCommandHandler( { public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.Albums - .SingleOrDefaultAsync(a => a.Id == request.AlbumId && a.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (album == null) - return AppErrors.NotFound("Album", request.AlbumId); - - if (album.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Album", request.AlbumId); + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var album = found.Value; album.LifeCycleStatus = LifeCycleStatus.Removing; await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked album {AlbumId} of user {UserId} as removing", request.AlbumId, request.UserId); + logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId); return Result.Success; } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 8a72541..20856b6 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -3,7 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Albums; @@ -17,22 +17,13 @@ public class GetAlbumByIdQueryHandler(IDatabase database) { var album = await database.Albums .AsNoTracking() - .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active) - .Select(a => new - { - Album = a, - TrackCount = a.AlbumTracks.Count, - CoverTrackIds = a.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) + .Active() + .Where(a => a.Id == request.Id) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (album == null) - return Error.NotFound(); - - return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds); + return album == null + ? AppErrors.NotFound("Album", request.Id) + : album; } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index 9138cc7..ba9955a 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -1,12 +1,10 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; -using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -23,27 +21,18 @@ public class GetAlbumTracksQueryHandler(IDatabase database) { var albumExists = await database.Albums .AsNoTracking() - .AnyAsync(album => album.Id == request.AlbumId && album.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + .Active() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); if (!albumExists) - return Error.NotFound(); + return AppErrors.NotFound("Album", request.AlbumId); - var tracksQuery = database.AlbumHasTracks + return await database.AlbumHasTracks .AsNoTracking() - .Include(albumTrack => albumTrack.Track.Owner) - .Include(albumTrack => albumTrack.Track.Tags) - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active); - - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .OrderBy(albumTrack => albumTrack.TrackNumber) - .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + .ThenBy(albumTrack => albumTrack.Id) + .Select(albumTrack => albumTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index 7bec513..0c7143e 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -1,59 +1,33 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using X.PagedList.EF; -using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Albums; public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) - : IQuery>; + : IQuery>; public class GetAlbumsQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler> { - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { var albumsQuery = database.Albums .AsNoTracking() - .Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); + .Active(); - if (!string.IsNullOrEmpty(request.Title)) + if (!string.IsNullOrWhiteSpace(request.Title)) { - var normalizedTitle = request.Title.Trim().ToUpperInvariant(); + var normalizedTitle = TextNormalizer.Normalize(request.Title); albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); } - var totalCount = await albumsQuery.CountAsync(cancellationToken); - - var pagedEntities = await albumsQuery + return await albumsQuery .OrderByDescending(album => album.CreatedAt) - .Select(album => new - { - Album = album, - TrackCount = album.AlbumTracks.Count, - CoverTrackIds = album.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var items = pagedEntities - .Select(entry => AlbumSearchItemResponse.FromAlbum( - AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))) - .ToList(); - - return new AlbumsSearchResponse( - items, - pagedEntities.PageNumber, - pagedEntities.PageSize, - pagedEntities.PageCount, - pagedEntities.TotalItemCount, - pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage); + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index f9f18ac..dfe03d2 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -using X.PagedList; -using X.PagedList.EF; -using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -22,32 +19,15 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database) .AsNoTracking() .AnyAsync(user => user.Id == request.UserId, cancellationToken); if (!userExists) - return Error.NotFound(description: $"User {request.UserId} not found"); + return AppErrors.NotFound("User", request.UserId); - var albumsQuery = database.Albums + return await database.Albums .AsNoTracking() - .Where(album => album.OwnerUserId == request.UserId && album.LifeCycleStatus == LifeCycleStatus.Active); - - var totalCount = await albumsQuery.CountAsync(cancellationToken); - - var pagedEntities = await albumsQuery + .Active() + .Where(album => album.OwnerUserId == request.UserId) .OrderByDescending(album => album.CreatedAt) - .Select(album => new - { - Album = album, - TrackCount = album.AlbumTracks.Count, - CoverTrackIds = album.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedAlbums = new StaticPagedList( - pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedAlbums); + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index bcee2e1..c038271 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -1,61 +1,51 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) - : IQuery>>; + : IQuery>; public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) { - var listenedTrackIds = database.ListeningHistories + var listens = database.ListeningHistories .AsNoTracking() .Where(history => history.UserId == request.UserId && history.IsCounted); var ranked = await database.AlbumHasTracks .AsNoTracking() + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Join( - listenedTrackIds, + listens, 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) + .ThenBy(entry => entry.AlbumId) .Take(request.Limit) .ToListAsync(cancellationToken); if (ranked.Count == 0) - return new List(); + return []; var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); - var albums = await database.Albums + var albumsById = await database.Albums .AsNoTracking() - .Where(album => albumIds.Contains(album.Id) && album.LifeCycleStatus == LifeCycleStatus.Active) - .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); + .Where(album => albumIds.Contains(album.Id)) + .SelectResponse() + .ToDictionaryAsync(album => 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)) + .Where(entry => albumsById.ContainsKey(entry.AlbumId)) + .Select(entry => albumsById[entry.AlbumId]) .ToList(); } } diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index b84c21c..2edb9c9 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -17,14 +17,9 @@ public class RemoveTrackFromAlbumCommandHandler( { public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.Albums - .AsNoTracking() - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - return AppErrors.NotFound("Album", request.AlbumId); - - if (album.OwnerUserId != request.UserId) - return AppErrors.Forbidden("Album", request.AlbumId); + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; var albumTracks = await database.AlbumHasTracks .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) @@ -33,10 +28,7 @@ public class RemoveTrackFromAlbumCommandHandler( var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); if (link == null) - { - logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId); - return Error.NotFound(description: "Track is not in the album."); - } + return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); database.AlbumHasTracks.Remove(link); @@ -48,6 +40,6 @@ public class RemoveTrackFromAlbumCommandHandler( logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index cf5731b..eae6f11 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -18,7 +18,7 @@ public record AlbumApplicationResponse( DateTime UpdatedAt, IReadOnlyList CoverTrackIds) { - public const int CoverTrackCount = 4; + public const int CoverTrackCount = AlbumProjections.CoverTrackCount; public static AlbumApplicationResponse FromEntity( Album album, diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs new file mode 100644 index 0000000..196d1d2 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs @@ -0,0 +1,31 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums.Responses; + +public static class AlbumProjections +{ + public const int CoverTrackCount = 4; + + public static IQueryable Active(this IQueryable query) => + query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(album => new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + album.AlbumTracks.Count, + album.Pictures!.SmallName, + album.Pictures!.MediumName, + album.Pictures!.LargeName, + album.CreatedAt, + album.UpdatedAt, + album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList())); +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs deleted file mode 100644 index 600494b..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Application.Albums.Responses; - -public record AlbumSearchItemResponse(AlbumApplicationResponse Album) -{ - public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album); -} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs deleted file mode 100644 index aa88d5b..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Musify.Application.Albums.Responses; - - -public record AlbumsSearchResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasPreviousPage, - bool HasNextPage); diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index e874017..4d58142 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -51,7 +51,7 @@ public class UpdateAlbumCommandHandler( var title = request.Title.Trim(); album.Title = title; - album.NormalizedTitle = title.ToUpperInvariant(); + album.NormalizedTitle = TextNormalizer.Normalize(title); album.Description = request.Description; album.ReleaseYear = request.ReleaseYear; @@ -118,7 +118,7 @@ public class UpdateAlbumCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/Application.csproj b/backend/Core/Musify.Application/Application.csproj index a424aa4..c1b27d7 100644 --- a/backend/Core/Musify.Application/Application.csproj +++ b/backend/Core/Musify.Application/Application.csproj @@ -19,8 +19,6 @@ - - diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index 733ba22..cffdd5f 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -1,12 +1,11 @@ using Mediator; using Musify.Domain.ValueObjects; +using Musify.Application.Genres.Responses; namespace Musify.Application.Genres; public record GetAvailableGenresQuery : IQuery>; -public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); - public class GetAvailableGenresQueryHandler : IQueryHandler> { diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index f85cbba..4d54270 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -2,13 +2,12 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Domain.ValueObjects; +using Musify.Application.Genres.Responses; namespace Musify.Application.Genres; public record GetGenresQuery : IQuery>; -public record GenreResponse(Genre Genre, int TrackCount); - public class GetGenresQueryHandler(IDatabase database) : IQueryHandler> { diff --git a/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs new file mode 100644 index 0000000..2ff905e --- /dev/null +++ b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs @@ -0,0 +1,5 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres.Responses; + +public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); diff --git a/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs new file mode 100644 index 0000000..60ae876 --- /dev/null +++ b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs @@ -0,0 +1,5 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres.Responses; + +public record GenreResponse(Genre Genre, int TrackCount); diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index d0468ad..3087e61 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -1,11 +1,8 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; using Musify.Domain.ValueObjects; namespace Musify.Application.Likes; @@ -14,30 +11,20 @@ public record GetLikedTracksQuery( long UserId, int PageNumber, int PageSize) - : IQuery>>; + : IQuery>; public class GetLikedTracksQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) { - var likesQuery = database.TrackLikes + return await database.TrackLikes .AsNoTracking() - .Include(like => like.Track.Owner) - .Include(like => like.Track.Tags) - .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active); - - var totalCount = await likesQuery.CountAsync(cancellationToken); - - var pagedEntities = await likesQuery + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) .OrderByDescending(like => like.CreatedAt) - .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + .ThenBy(like => like.Id) + .Select(like => like.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 468a655..352eb0a 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Likes; @@ -20,12 +21,9 @@ public class ToggleTrackLikeCommandHandler( { var trackExists = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } + return AppErrors.NotFound("Track", request.TrackId); var existingLike = await database.TrackLikes .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index bc45f5e..587aa3a 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -33,7 +33,7 @@ public class GenerateMixesForUserCommandHandler( if (libraryTrackIds.Count == 0) { logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); - return new Success(); + return Result.Success; } var historyTrackIds = await database.ListeningHistories @@ -69,23 +69,15 @@ public class GenerateMixesForUserCommandHandler( if (drafts.Count == 0) { logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return new Success(); + return Result.Success; } await ReplaceMixesAsync(userId, drafts, cancellationToken); - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId); - return Error.Failure(description: $"Failed to generate mixes for user {userId}"); - } + await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); - return new Success(); + return Result.Success; } private List Take(IEnumerable trackIds) => diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index 75752ac..a831c12 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; +using Musify.Application.Shared; namespace Musify.Application.Mixes; @@ -20,7 +21,7 @@ public class GetMixByIdQueryHandler(IDatabase database) .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); if (mix == null) - return Error.NotFound(description: $"Mix {request.MixId} was not found"); + return AppErrors.NotFound("Mix", request.MixId); var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index c40290d..bcb68b3 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; @@ -7,18 +6,19 @@ using Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes; public record GetMixesByUserIdQuery(long UserId) - : IQuery>>; + : IQuery>; public class GetMixesByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) { var mixes = await database.Mixes .AsNoTracking() .Where(mix => mix.UserId == request.UserId) .Include(mix => mix.Items) .OrderBy(mix => mix.Position) + .ThenBy(mix => mix.Id) .ToListAsync(cancellationToken); var orderedItemsByMix = mixes.ToDictionary( diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs index 3626d30..7e9184a 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -18,18 +19,13 @@ public class AddTrackToPlayListCommandHandler( { public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists - .AsNoTracking() - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - return AppErrors.NotFound("PlayList", request.PlayListId); - - if (playList.OwnerUserId != request.UserId) - return AppErrors.Forbidden("PlayList", request.PlayListId); + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; var trackExists = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (!trackExists) return AppErrors.NotFound("Track", request.TrackId); diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 6b1d66b..76b8e2b 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; @@ -53,7 +54,7 @@ public class CreatePlayListCommandHandler( { OwnerUserId = request.UserId, Name = request.Name.Trim(), - NormalizedName = request.Name.Trim().ToUpperInvariant(), + NormalizedName = TextNormalizer.Normalize(request.Name), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Pictures = resolvedPictures.Pictures, Visibility = request.Visibility @@ -119,7 +120,7 @@ public class CreatePlayListCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index e49b65c..6dad744 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -1,10 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -20,43 +19,18 @@ public class DeletePlayListCommandHandler( { public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.OwnerUserId != request.UserId) - { - logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); - return AppErrors.Forbidden("PlayList", request.PlayListId); - } + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var playList = found.Value; playList.LifeCycleStatus = LifeCycleStatus.Removing; - try - { - await eventBus.PublishAsync( - new DeletePlayListEvent(playList.Id, request.UserId), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId); - return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); - } + await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId); - return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); - } + logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 825861d..8cbfdae 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -3,7 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; -using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.PlayLists; @@ -15,29 +15,16 @@ public class GetPlayListByIdQueryHandler(IDatabase database) { public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) { - var entry = await database.PlayLists + var playList = await database.PlayLists .AsNoTracking() - .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active) - .Select(p => new - { - PlayList = p, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) + .Active() + .VisibleTo(request.RequestingUserId) + .Where(p => p.Id == request.Id) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (entry == null) - return Error.NotFound(); - - if (entry.PlayList.Visibility == PlayListVisibility.Private - && entry.PlayList.OwnerUserId != request.RequestingUserId) - { - return Error.NotFound(); - } - - return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); + return playList == null + ? AppErrors.NotFound("PlayList", request.Id) + : playList; } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index d99ffee..6aa90db 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -2,11 +2,10 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -using X.PagedList; -using X.PagedList.EF; namespace Musify.Application.PlayLists; @@ -22,33 +21,21 @@ public class GetPlayListTracksQueryHandler(IDatabase database) { public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) { - var playListExists = await database.PlayLists + var playListVisible = await database.PlayLists .AsNoTracking() - .AnyAsync( - p => p.Id == request.PlayListId - && p.LifeCycleStatus == LifeCycleStatus.Active - && (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId), - cancellationToken); - if (!playListExists) - return Error.NotFound(); + .Active() + .VisibleTo(request.ViewerId) + .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); + if (!playListVisible) + return AppErrors.NotFound("PlayList", request.PlayListId); - var tracksQuery = database.PlayListHasTracks + return await database.PlayListHasTracks .AsNoTracking() - .Include(plt => plt.Track.Owner) - .Include(plt => plt.Track.Tags) - .Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active); - - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderBy(plt => plt.Position) - .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(playListTrack => playListTrack.Position) + .ThenBy(playListTrack => playListTrack.Id) + .Select(playListTrack => playListTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index c9d2e05..6105348 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -1,12 +1,8 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; -using Musify.Domain.ValueObjects; -using X.PagedList; -using X.PagedList.EF; namespace Musify.Application.PlayLists; @@ -16,45 +12,29 @@ public record GetPlayListsByUserIdQuery( int PageNumber, int PageSize, long? ViewerId = null) - : IQuery>>; + : IQuery>; public class GetPlayListsByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) { var playListsQuery = database.PlayLists .AsNoTracking() - .Where(p => p.OwnerUserId == request.UserId - && p.LifeCycleStatus == LifeCycleStatus.Active - && (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId)); + .Active() + .VisibleTo(request.ViewerId) + .Where(playList => playList.OwnerUserId == request.UserId); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName)); } - var totalCount = await playListsQuery.CountAsync(cancellationToken); - - var pagedEntities = await playListsQuery - .OrderBy(p => p.CreatedAt) - .ThenBy(p => p.Id) - .Select(p => new - { - PlayList = p, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedPlayLists); + return await playListsQuery + .OrderBy(playList => playList.CreatedAt) + .ThenBy(playList => playList.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index 6f4c9ca..e6c44c2 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -17,14 +17,9 @@ public class RemoveTrackFromPlayListCommandHandler( { public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) { - var playList = await database.PlayLists - .AsNoTracking() - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - return AppErrors.NotFound("PlayList", request.PlayListId); - - if (playList.OwnerUserId != request.UserId) - return AppErrors.Forbidden("PlayList", request.PlayListId); + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; var playListTracks = await database.PlayListHasTracks .Where(plt => plt.PlayListId == request.PlayListId) diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 527a324..8b9ddb4 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,6 +1,9 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; +using System.Text.Json.Serialization; +using Musify.Application.Serialization; + namespace Musify.Application.PlayLists.Responses; public record PlayListApplicationResponse( @@ -13,13 +16,18 @@ public record PlayListApplicationResponse( PlayListVisibility Visibility, DateTime CreatedAt, DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) + IReadOnlyList CoverTrackIds, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + int TrackCount, + double DurationSeconds) { - public const int CoverTrackCount = 4; + public const int CoverTrackCount = PlayListProjections.CoverTrackCount; public static PlayListApplicationResponse FromEntity( PlayList playList, - IReadOnlyList? coverTrackIds = null) + IReadOnlyList? coverTrackIds = null, + int trackCount = 0, + double durationSeconds = 0) { return new PlayListApplicationResponse( playList.Id, @@ -31,6 +39,9 @@ public record PlayListApplicationResponse( playList.Visibility, playList.CreatedAt, playList.UpdatedAt, - coverTrackIds ?? []); + coverTrackIds ?? [], + playList.OwnerUserId, + trackCount, + durationSeconds); } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs new file mode 100644 index 0000000..9e23757 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs @@ -0,0 +1,37 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists.Responses; + +public static class PlayListProjections +{ + public const int CoverTrackCount = 4; + + public static IQueryable Active(this IQueryable query) => + query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => + query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(playList => new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures!.SmallName, + playList.Pictures!.MediumName, + playList.Pictures!.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + playList.PlayListTracks + .OrderBy(playListTrack => playListTrack.Position) + .Take(CoverTrackCount) + .Select(playListTrack => playListTrack.TrackId) + .ToList(), + playList.OwnerUserId, + playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), + playList.PlayListTracks + .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Sum(playListTrack => playListTrack.Track.DurationSeconds))); +} diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 6b329df..53a2dd1 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -50,7 +50,7 @@ public class UpdatePlayListCommandHandler( if (!string.IsNullOrWhiteSpace(request.NewName)) { playListEntity.Name = request.NewName.Trim(); - playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); + playListEntity.NormalizedName = TextNormalizer.Normalize(request.NewName); } if (request.NewDescription != null) @@ -123,7 +123,7 @@ public class UpdatePlayListCommandHandler( sizes.Medium, sizes.Large), cancellationToken); - return new Success(); + return Result.Success; } catch (Exception exception) { diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index a14bd68..039e57e 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -4,6 +4,7 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Services; @@ -30,7 +31,7 @@ public sealed class UploadIntentValidator( if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser) return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); - return new Success(); + return Result.Success; } public async Task> ValidateAndLoadAsync( @@ -44,20 +45,20 @@ public sealed class UploadIntentValidator( .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); if (intent == null || intent.UserId != userId) - return Error.NotFound(description: "Upload intent not found or not accessible."); + return AppErrors.NotFound("UploadIntent", intentId); if (intent.Purpose != purpose) return Error.Validation(description: "Upload intent was issued for a different purpose."); if (intent.IsConsumed) - return Error.Conflict(description: "Upload intent has already been consumed."); + return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed."); if (intent.IsExpiredAt(DateTime.UtcNow)) return Error.Validation(description: "Upload intent has expired."); var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); if (metadata == null) - return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first."); + return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first."); if (metadata.ContentLength > config.MaxUploadBytes) return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); diff --git a/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs new file mode 100644 index 0000000..5ad6072 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs @@ -0,0 +1,28 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Abstractions; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Shared; + +public static class OwnershipExtensions +{ + /// + /// Loads an active, tracked entity owned by : NotFound when it is missing + /// or not active, Forbidden when someone else owns it. + /// + public static async Task> FindOwnedAsync( + this DbSet set, Guid id, long userId, CancellationToken cancellationToken) + where T : class, IOwnedEntity, IHasLifeCycle + { + var entity = await set.SingleOrDefaultAsync( + e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + + if (entity == null) + return AppErrors.NotFound(typeof(T).Name, id); + + return entity.OwnerUserId == userId + ? entity + : AppErrors.Forbidden(typeof(T).Name, id); + } +} diff --git a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs b/backend/Core/Musify.Application/Shared/PaginatedResponse.cs deleted file mode 100644 index e77284b..0000000 --- a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs +++ /dev/null @@ -1,25 +0,0 @@ -using X.PagedList; - -namespace Musify.Application.Shared; - -public record PaginatedResponse( - IReadOnlyCollection Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasNextPage, - bool HasPreviousPage) -{ - public static PaginatedResponse FromPagedList(IPagedList pagedList) - { - return new PaginatedResponse( - pagedList.ToArray(), - pagedList.PageNumber, - pagedList.PageSize, - pagedList.PageCount, - pagedList.TotalItemCount, - pagedList.HasNextPage, - pagedList.HasPreviousPage); - } -} diff --git a/backend/Core/Musify.Application/Shared/Pagination.cs b/backend/Core/Musify.Application/Shared/Pagination.cs index b32e156..451669e 100644 --- a/backend/Core/Musify.Application/Shared/Pagination.cs +++ b/backend/Core/Musify.Application/Shared/Pagination.cs @@ -1,6 +1,45 @@ +using Microsoft.EntityFrameworkCore; + namespace Musify.Application.Shared; public record PageRequest(int PageNumber = 1, int PageSize = 20) { public const int MaxPageSize = 100; } + +public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); + +public static class PaginationExtensions +{ + /// Counts and pages the same (already filtered, ordered and projected) query. + public static async Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken) + { + var pageNumber = Math.Max(page.PageNumber, 1); + var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize); + + var totalCount = await query.CountAsync(cancellationToken); + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PaginatedResponse( + items, + pageNumber, + pageSize, + pageCount, + totalCount, + HasNextPage: pageNumber < pageCount, + HasPreviousPage: pageNumber > 1 && pageCount > 0); + } +} diff --git a/backend/Core/Musify.Application/Shared/TextNormalizer.cs b/backend/Core/Musify.Application/Shared/TextNormalizer.cs new file mode 100644 index 0000000..e0c3e46 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/TextNormalizer.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Shared; + +public static class TextNormalizer +{ + public static string Normalize(string value) => value.Trim().ToUpperInvariant(); +} diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 1f2cfc1..cd4ed2a 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Application.Services; using Musify.Application.Tracks.Responses; @@ -82,8 +83,8 @@ public class CreateTrackCommandHandler( var trackEntity = new Track { - Title = request.Title, - NormalizedTitle = request.Title.ToUpperInvariant(), + Title = request.Title.Trim(), + NormalizedTitle = TextNormalizer.Normalize(request.Title), OwnerUserId = user.Id, Owner = user, IsExplicit = request.IsExplicit, diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index e4d75bb..bde1aa1 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -1,10 +1,9 @@ using ErrorOr; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -22,49 +21,21 @@ public class DeleteTrackCommandHandler( { public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) { - var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track == null) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(); - } - - if (track.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); - return AppErrors.Forbidden("Track", request.TrackId); - } + var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + var track = found.Value; if (track.Audio.IsInProgress) - { - logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId); - return Error.Conflict(description: "Track is currently being processed and cannot be deleted"); - } + return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted."); track.LifeCycleStatus = LifeCycleStatus.Removing; - try - { - await eventBus.PublishAsync( - new DeleteTrackEvent(track.Id, request.UserId), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId); - return Error.Failure(description: $"Failed to delete track {request.TrackId}"); - } + await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId); - return Error.Failure(description: $"Failed to delete track {request.TrackId}"); - } + logger.LogInformation("Marked track {TrackId} as removing", request.TrackId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index e73095f..2a44802 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -2,40 +2,38 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; -public record GetListeningHistoryQuery(long UserId) : IQuery>; +public record GetListeningHistoryQuery(long UserId) : IQuery>; -public class GetListeningHistoryQueryHandler( - IDatabase database) - : IQueryHandler> +public class GetListeningHistoryQueryHandler(IDatabase database) + : IQueryHandler> { public const int ListSize = 15; - public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) { var recentTrackIds = await database.ListeningHistories .AsNoTracking() .Where(l => l.UserId == query.UserId && l.IsCounted) .GroupBy(l => l.TrackId) .OrderByDescending(g => g.Max(l => l.ListenedAt)) + .ThenBy(g => g.Key) .Select(g => g.Key) .Take(ListSize) .ToListAsync(cancellationToken); var tracksById = await database.Tracks .AsNoTracking() - .Include(t => t.Owner) - .Include(t => t.Tags) - .Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + .Active() + .Where(t => recentTrackIds.Contains(t.Id)) + .SelectResponse() .ToDictionaryAsync(t => t.Id, cancellationToken); return recentTrackIds .Where(id => tracksById.ContainsKey(id)) .Select(id => tracksById[id]) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount)); + .ToList(); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 7f4a1a1..3614661 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -2,8 +2,8 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -15,16 +15,15 @@ public class GetTrackByIdQueryHandler(IDatabase database) { public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { - var entity = await database.Tracks.AsNoTracking() - .Include(t => t.Owner) - .Include(t => t.Tags) - .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + var track = await database.Tracks + .AsNoTracking() + .Active() + .Where(t => t.Id == request.TrackId) + .SelectResponse() .SingleOrDefaultAsync(cancellationToken); - if (entity == null) - return Error.NotFound(); - - return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount); + return track == null + ? AppErrors.NotFound("Track", request.TrackId) + : track; } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs index 3e8f602..51a963c 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs @@ -3,6 +3,9 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; +using MimeMapping; namespace Musify.Application.Tracks; @@ -20,12 +23,12 @@ public class GetTrackCoverQueryHandler( public async ValueTask> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) { var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId) + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) .SingleOrDefaultAsync(cancellationToken); if (track == null) - return Error.NotFound(); + return AppErrors.NotFound("TrackCover", request.TrackId); var routes = trackConfiguration.Routes; var size = PictureSizeParser.Parse(request.Size); @@ -38,13 +41,9 @@ public class GetTrackCoverQueryHandler( var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name); if (string.IsNullOrEmpty(name) || key == null) - return Error.NotFound(); + return AppErrors.NotFound("TrackCover", request.TrackId); - var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) - ? "image/webp" - : name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) - ? "image/png" - : "image/jpeg"; + var contentType = MimeUtility.GetMimeMapping(name); return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs index 2204afe..6678931 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs @@ -7,6 +7,7 @@ using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; +using Musify.Application.Shared; namespace Musify.Application.Tracks; @@ -32,13 +33,10 @@ public class GetTrackStreamQueryHandler( .SingleOrDefaultAsync(cancellationToken); if (track == null) - return Error.NotFound(); + return AppErrors.NotFound("Track", request.TrackId); if (!track.Audio.IsProcessed) - { - logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId); - return Error.Conflict(description: "Track audio is not available for streaming yet."); - } + return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 6fc1d44..6120e24 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -1,55 +1,37 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -using X.PagedList.EF; namespace Musify.Application.Tracks; public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) - : IQuery>; + : IQuery>; public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler> { - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { - var tracksQuery = database.UserHasTracks + var tracksQuery = database.Tracks .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active); + .Active(); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); } if (request.Genre is { } genre) - tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre)); + tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre)); - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderByDescending(t => t.Track.CreatedAt) - .ThenBy(t => t.Id) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var items = pagedEntities - .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) - .ToList(); - - return new TracksSearchResponse( - items, - pagedEntities.PageNumber, - pagedEntities.PageSize, - pagedEntities.PageCount, - pagedEntities.TotalItemCount, - pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage); + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index e03ecc8..5ec82be 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -22,29 +19,21 @@ public class GetTracksByUserIdQueryHandler(IDatabase database) { public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { - var tracksQuery = database.UserHasTracks + var tracksQuery = database.Tracks .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active); + .Active() + .Where(track => track.OwnerUserId == request.UserId); - if (!string.IsNullOrEmpty(request.Name)) + if (!string.IsNullOrWhiteSpace(request.Name)) { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); } - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderBy(t => t.Track.CreatedAt) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index d3a5907..0cc95cc 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.Tracks; @@ -24,7 +25,7 @@ public class RecordListeningProgressCommandHandler(IDatabase database) .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); if (listen == null) - return Error.NotFound(); + return AppErrors.NotFound("Listen", request.ListenId); var now = DateTime.UtcNow; var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs new file mode 100644 index 0000000..33fa4f8 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -0,0 +1,24 @@ +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses; + +public static class TrackProjections +{ + public static IQueryable Active(this IQueryable query) => + query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); + + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(track => new TrackApplicationResponse( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + track.ListeningHistories.Count(listen => listen.IsCounted), + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit)); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs deleted file mode 100644 index 9e7af80..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Application.Tracks.Responses; - -public record TrackSearchItemResponse(TrackApplicationResponse Track) -{ - public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); -} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs deleted file mode 100644 index c454450..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Musify.Application.Tracks.Responses; - - -public record TracksSearchResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasPreviousPage, - bool HasNextPage); diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index 345fc89..bcb4441 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -24,16 +24,13 @@ public class FollowUserCommandHandler( .AsNoTracking() .AnyAsync(u => u.Id == request.FollowedId, cancellationToken); if (!followedExists) - { - logger.LogInformation("User {FollowedId} not found", request.FollowedId); - return Error.NotFound(description: $"User {request.FollowedId} not found"); - } + return AppErrors.NotFound("User", request.FollowedId); var alreadyFollowing = await database.UserFollows .AsNoTracking() .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); if (alreadyFollowing) - return new Success(); + return Result.Success; await database.UserFollows.AddAsync(new UserFollow { @@ -49,6 +46,6 @@ public class FollowUserCommandHandler( logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index a54880a..a1fcd1d 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -1,13 +1,12 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.Users.Responses; namespace Musify.Application.Users; public record GetListeningStatsQuery(long UserId) : IQuery; -public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); - public class GetListeningStatsQueryHandler(IDatabase database) : IQueryHandler { diff --git a/backend/Core/Musify.Application/Users/GetUserById.cs b/backend/Core/Musify.Application/Users/GetUserById.cs index 989cb04..3069355 100644 --- a/backend/Core/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Users.Responses; +using Musify.Application.Shared; namespace Musify.Application.Users; @@ -20,9 +21,8 @@ public class GetUserByIdQueryHandler(IDatabase database) .Select(u => UserApplicationResponse.FromEntity(u)) .SingleOrDefaultAsync(cancellationToken); - if (user == null) - return Error.NotFound(); - - return user; + return user == null + ? AppErrors.NotFound("User", request.Id) + : user; } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs index 8f280e1..90284fb 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowers.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users; @@ -17,20 +16,18 @@ public class GetUserFollowersQueryHandler(IDatabase database) public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) { if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) - return Error.Forbidden(); + return AppErrors.Forbidden("User", request.UserId); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowedId == request.UserId); - - var totalCount = await followsQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var page = await followsQuery + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId) .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) .Select(f => new UserSummaryResponse( f.Follower.Id, f.Follower.Name, @@ -38,8 +35,6 @@ public class GetUserFollowersQueryHandler(IDatabase database) f.Follower.SecondName, f.Follower.ProfilePictureUrl, viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(page); + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs index 89279fa..50c7c1b 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowing.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users; @@ -17,17 +16,15 @@ public class GetUserFollowingQueryHandler(IDatabase database) public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) { if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowerId == request.UserId); - - var totalCount = await followsQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var page = await followsQuery + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId) .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) .Select(f => new UserSummaryResponse( f.Followed.Id, f.Followed.Name, @@ -35,8 +32,6 @@ public class GetUserFollowingQueryHandler(IDatabase database) f.Followed.SecondName, f.Followed.ProfilePictureUrl, viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(page); + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index 88039db..cf9796e 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -1,26 +1,14 @@ -using System.Text.Json.Serialization; using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -using Musify.Application.Serialization; +using Musify.Application.Shared; +using Musify.Application.Users.Responses; namespace Musify.Application.Users; public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; -public record UserProfileResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - int FollowersCount, - int FollowingCount, - bool IsFollowing, - bool CanViewFollowers); - public class GetUserProfileQueryHandler(IDatabase database) : IQueryHandler> { @@ -41,7 +29,7 @@ public class GetUserProfileQueryHandler(IDatabase database) .SingleOrDefaultAsync(cancellationToken); if (user == null) - return Error.NotFound(); + return AppErrors.NotFound("User", request.UserId); var followersCount = await database.UserFollows .AsNoTracking() diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index cbe48bb..1dcffd7 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -1,43 +1,40 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -using X.PagedList.EF; namespace Musify.Application.Users; public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) - : IQuery>>; + : IQuery>; public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler> { - public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetUsersQuery request, CancellationToken cancellationToken) { var usersQuery = database.Users .AsNoTracking(); - if (!string.IsNullOrEmpty(request.UsernameSearch)) + if (!string.IsNullOrWhiteSpace(request.UsernameSearch)) { - var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); - usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername)); + var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch); + usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername)); } - var totalCount = await usersQuery.CountAsync(cancellationToken); var viewerId = request.ViewerId; - var pagedUsers = await usersQuery - .OrderBy(u => u.Name) - .Select(u => new UserSummaryResponse( - u.Id, - u.Name, - u.FirstName, - u.SecondName, - u.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - return PaginatedResponse.FromPagedList(pagedUsers); + return await usersQuery + .OrderBy(user => user.Name) + .ThenBy(user => user.Id) + .Select(user => new UserSummaryResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs new file mode 100644 index 0000000..c12d7ce --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs @@ -0,0 +1,3 @@ +namespace Musify.Application.Users.Responses; + +public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); diff --git a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs new file mode 100644 index 0000000..b1d9520 --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Musify.Application.Serialization; + +namespace Musify.Application.Users.Responses; + +public record UserProfileResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + int FollowersCount, + int FollowingCount, + bool IsFollowing, + bool CanViewFollowers); diff --git a/backend/Core/Musify.Application/Users/SyncUser.cs b/backend/Core/Musify.Application/Users/SyncUser.cs index 2d94958..ff5c6ac 100644 --- a/backend/Core/Musify.Application/Users/SyncUser.cs +++ b/backend/Core/Musify.Application/Users/SyncUser.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Users; @@ -29,7 +30,7 @@ public class SyncUserCommandHandler(IDatabase database, ILogger 0) logger.LogInformation("Synced profile for user {UserId}", request.Id); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs index 432d3b2..49c24d6 100644 --- a/backend/Core/Musify.Application/Users/UnfollowUser.cs +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -19,13 +19,13 @@ public class UnfollowUserCommandHandler( .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); if (follow == null) - return new Success(); + return Result.Success; database.UserFollows.Remove(follow); await database.SaveChangesAsync(cancellationToken); logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs index bb56244..d65c9fb 100644 --- a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs +++ b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs @@ -2,7 +2,5 @@ namespace Musify.Domain.Abstractions; public interface IOwnedEntity { - public Guid Id { get; } - public long OwnerUserId { get; } } diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index aa0fa86..eb7442c 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -45,8 +45,6 @@ - - diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 8798d78..d75a5b5 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -25,7 +25,7 @@ public static class AlbumEndpoints .AddEndpointFilter>() .WithName("GetAlbums") .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") - .Produces(); + .Produces>(); group.MapGet("/{id}", GetAlbumById) .WithName("GetAlbumById") @@ -129,7 +129,7 @@ public static class AlbumEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetAlbumById( @@ -166,7 +166,7 @@ public static class AlbumEndpoints } var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetAlbumTracks( diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs index da31c5b..382655b 100644 --- a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -1,5 +1,6 @@ using Mediator; using Musify.Application.Genres; +using Musify.Application.Genres.Responses; namespace Musify.Api.Endpoints; diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index 4c74e53..22f747e 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -42,7 +42,7 @@ public static class LikeEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task ToggleTrackLike( diff --git a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs index 41a79cd..371cbde 100644 --- a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs @@ -36,7 +36,7 @@ public static class MixEndpoints CancellationToken cancellationToken) { var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetMixById( diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index dd57a99..d3daa30 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -126,7 +126,7 @@ public static class PlayListEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetPlayListTracks( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index dcbf00b..a718e6d 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -23,7 +23,7 @@ public static class TrackEndpoints .AddEndpointFilter>() .WithName("GetTracks") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") - .Produces() + .Produces>() .ProducesValidationProblem(); group.MapGet("/{id}", GetTrackById) @@ -116,7 +116,7 @@ public static class TrackEndpoints } var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetTrackById( diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index af38b56..5744d64 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -105,7 +105,7 @@ public static class UserEndpoints [AsParameters] PageQuery page) { var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); - return result.ToHttpResult(); + return Results.Ok(result); } private static async Task GetUserById( diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index 3292381..b8790cb 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -4,7 +4,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Musify.Api.Tests.TestSupport; -using Musify.Application.Genres; +using Musify.Application.Genres.Responses; +using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -85,8 +86,8 @@ public sealed class GenreEndpointsTests(ApiTestFixture fixture) var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Track.Title == title); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); } [Fact] @@ -98,8 +99,8 @@ public sealed class GenreEndpointsTests(ApiTestFixture fixture) var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Track.Title == title); - Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags)); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags)); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index 458ab85..b0df965 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -15,10 +15,8 @@ public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); + Assert.Equal(2, result.TotalItemCount); + Assert.All(result.Items, item => Assert.NotNull(item)); } [Fact] @@ -28,10 +26,8 @@ public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Rock Classics", item.Album.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Rock Classics", item.Title); } [Fact] @@ -41,9 +37,7 @@ public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.True(result.Value.HasNextPage); + Assert.Equal(2, result.Items.Count); + Assert.True(result.HasNextPage); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index 080d994..df36f5b 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -25,9 +25,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1))); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title)); + Assert.Equal(["Newer", "Older"], result.Select(album => album.Title)); } [Fact] @@ -37,9 +35,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync(owner); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value); + Assert.Empty(result); } [Fact] @@ -53,9 +49,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Count); + Assert.Equal(2, result.Count); } [Fact] @@ -75,6 +69,6 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - Assert.Equal(["Counted album"], result.Value.Select(album => album.Title)); + Assert.Equal(["Counted album"], result.Select(album => album.Title)); } } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index bba8f87..29fd50d 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -1,6 +1,7 @@ using Musify.Application.Genres; using Musify.Domain.ValueObjects; using Xunit; +using Musify.Application.Genres.Responses; namespace Musify.Application.Tests.Genres; diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index ae6a239..9234f5b 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -21,9 +21,7 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title)); + Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title)); } [Fact] @@ -33,9 +31,7 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase await SeedAsync(owner); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); + Assert.Empty(result.Items); } [Fact] @@ -47,8 +43,6 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); + Assert.Empty(result.Items); } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs index 9533607..9a20c90 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -19,9 +19,7 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase await SeedAsync(owner, other, second, first, theirs); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title)); + Assert.Equal(["First", "Second"], result.Select(mix => mix.Title)); } [Fact] @@ -31,8 +29,6 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase await SeedAsync(owner); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value); + Assert.Empty(result); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 5ffe334..76b0a75 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -21,9 +21,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(other.Id, "Theirs")); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var playList = Assert.Single(result.Items); Assert.Equal("Mine", playList.Name); } @@ -37,9 +35,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var playList = Assert.Single(result.Items); Assert.Equal("Public one", playList.Name); } @@ -53,9 +49,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); + Assert.Equal(2, result.Items.Count); } [Fact] @@ -65,9 +59,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus")); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); + var playList = Assert.Single(result.Items); Assert.Equal("Road Trip", playList.Name); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index 869ab14..88a12c1 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -21,10 +21,8 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id }); var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.Equal(2, result.Value.Items.Count); + Assert.Equal(2, result.TotalItemCount); + Assert.Equal(2, result.Items.Count); } [Fact] @@ -39,10 +37,8 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Bohemian Rhapsody", item.Track.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Bohemian Rhapsody", item.Title); } [Fact] @@ -57,9 +53,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Loud", item.Track.Title); + var item = Assert.Single(result.Items); + Assert.Equal("Loud", item.Title); } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs index fe1dc64..9734a7b 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -15,9 +15,7 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); + Assert.Equal(2, result.TotalItemCount); } [Fact] @@ -26,9 +24,7 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var user = Assert.Single(result.Value.Items); + var user = Assert.Single(result.Items); Assert.Equal(1, user.Id); } @@ -40,8 +36,6 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase new UserFollow { FollowerId = 1, FollowedId = 2 }); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 57fb27d..6e889eb 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -763,22 +763,6 @@ export interface components { expiresInSeconds: number | string; uploadUrl: string; }; - AlbumSearchItemResponse: { - album: components["schemas"]["AlbumApplicationResponse"]; - }; - AlbumsSearchResponse: { - items: components["schemas"]["AlbumSearchItemResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasPreviousPage: boolean; - hasNextPage: boolean; - }; AvailableGenreResponse: { genre: components["schemas"]["Genre"]; incompatibleWith: components["schemas"]["Genre"][]; @@ -796,7 +780,7 @@ export interface components { description: null | string; /** Format: uuid */ pictureIntentId: null | string; - visibility?: components["schemas"]["PlaylistVisibility"]; + visibility?: components["schemas"]["PlayListVisibility"]; }; CreateTrackRequest: { title: string; @@ -918,12 +902,17 @@ export interface components { smallImageKeyName: null | string; mediumImageKeyName: null | string; largeImageKeyName: null | string; - visibility: components["schemas"]["PlaylistVisibility"]; + visibility: components["schemas"]["PlayListVisibility"]; /** Format: date-time */ createdAt: string; /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; + ownerUserId: string; + /** Format: int32 */ + trackCount: number | string; + /** Format: double */ + durationSeconds: number | string; }; PlayListPictureUploadResponse: { /** Format: uuid */ @@ -937,7 +926,7 @@ export interface components { uploadUrl: string; }; /** @enum {unknown} */ - PlaylistVisibility: "Private" | "Public"; + PlayListVisibility: "Private" | "Public"; /** @enum {unknown} */ ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; RecordListeningProgressRequest: { @@ -988,22 +977,6 @@ export interface components { tags: components["schemas"]["Genre"][]; isExplicit: boolean; }; - TrackSearchItemResponse: { - track: components["schemas"]["TrackApplicationResponse"]; - }; - TracksSearchResponse: { - items: components["schemas"]["TrackSearchItemResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasPreviousPage: boolean; - hasNextPage: boolean; - }; TrackStreamResponse: { manifestUrl: string; ticket: string; @@ -1042,7 +1015,7 @@ export interface components { newDescription: null | string; /** Format: uuid */ newPictureIntentId: null | string; - newVisibility?: null | components["schemas"]["PlaylistVisibility"]; + newVisibility?: null | components["schemas"]["PlayListVisibility"]; }; UserApplicationResponse: { id: string; @@ -1106,7 +1079,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AlbumsSearchResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"]; }; }; }; @@ -2158,7 +2131,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TracksSearchResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; /** @description Bad Request */ diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 9cb30d2..3f4f24f 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -63,7 +63,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { genre, genres, tracks, - albums: (albumsRes?.data?.items ?? []).map((item) => item.album), + albums: albumsRes?.data?.items ?? [], users: usersRes?.data?.items ?? [], viewerId: user?.sub ?? null, playlists: playlistsRes?.data?.items ?? [] diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index de92dc7..0b1f5c7 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -49,7 +49,7 @@ let albumMenu = $state(null); let sfilter = $state('Todo'); - type LocalTrack = (typeof data.tracks.items)[number]['track']; + type LocalTrack = (typeof data.tracks.items)[number]; let localItems = $state([]); let localPage = $state(1); @@ -60,7 +60,7 @@ trackMenu.close(); albumMenu = null; sfilter = data.genre ? 'Canciones' : 'Todo'; - localItems = data.tracks.items.map((item) => item.track); + localItems = data.tracks.items; localPage = Number(data.tracks.pageNumber); hasMore = data.tracks.hasNextPage; }); @@ -177,11 +177,7 @@ const res = await fetch(`/api/tracks?${params}`); if (!res.ok) throw new Error(String(res.status)); const next = (await res.json()) as typeof data.tracks; - localItems = appendUnique( - localItems, - next.items.map((item) => item.track), - (track) => track.id - ); + localItems = appendUnique(localItems, next.items, (track) => track.id); localPage = Number(next.pageNumber); hasMore = next.hasNextPage; } catch { diff --git a/web-player/src/routes/+page.server.ts b/web-player/src/routes/+page.server.ts index ab745b2..791961b 100644 --- a/web-player/src/routes/+page.server.ts +++ b/web-player/src/routes/+page.server.ts @@ -40,7 +40,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { recentlyPlayedPromise ]); const playlistItems = playlists.data?.items ?? []; - const latestItems = (latest.data?.items ?? []).map((item) => item.track); + const latestItems = latest.data?.items ?? []; const spotlightPlaylist = playlistItems[0] ?? null; const spotlightTracksPromise = spotlightPlaylist