Add shared pagination, projections and ownership helpers; simplify query and command handlers

This commit is contained in:
2026-09-24 11:59:34 +02:00
parent 5a0f82b77b
commit 4b147d9ee3
84 changed files with 547 additions and 767 deletions
@@ -18,23 +18,13 @@ public class AddTrackToAlbumCommandHandler(
{
public async ValueTask<ErrorOr<Success>> 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()
@@ -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)
{
@@ -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<ErrorOr<Success>> 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;
}
@@ -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;
}
}
@@ -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<TrackApplicationResponse>(
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -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<ErrorOr<AlbumsSearchResponse>>;
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> 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);
}
}
@@ -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<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -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<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> 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<AlbumApplicationResponse>();
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();
}
}
@@ -17,14 +17,9 @@ public class RemoveTrackFromAlbumCommandHandler(
{
public async ValueTask<ErrorOr<Success>> 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;
}
}
@@ -18,7 +18,7 @@ public record AlbumApplicationResponse(
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = 4;
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
public static AlbumApplicationResponse FromEntity(
Album album,
@@ -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<Album> Active(this IQueryable<Album> query) =>
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> 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()));
}
@@ -1,6 +0,0 @@
namespace Musify.Application.Albums.Responses;
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
{
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
}
@@ -1,11 +0,0 @@
namespace Musify.Application.Albums.Responses;
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
@@ -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)
{
@@ -19,8 +19,6 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
@@ -1,12 +1,11 @@
using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres;
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
@@ -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<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
@@ -0,0 +1,5 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
@@ -0,0 +1,5 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record GenreResponse(Genre Genre, int TrackCount);
@@ -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<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> 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<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
.ThenBy(like => like.Id)
.Select(like => like.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -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);
@@ -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}");
}
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return new Success();
return Result.Success;
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
@@ -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);
@@ -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<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
: IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
{
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<MixApplicationResponse>> 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(
@@ -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<ErrorOr<Success>> 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);
@@ -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)
{
@@ -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<ErrorOr<Success>> 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}");
}
try
{
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
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}");
}
return new Success();
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
return Result.Success;
}
}
@@ -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<ErrorOr<PlayListApplicationResponse>> 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;
}
}
@@ -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<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> 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<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.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);
}
}
@@ -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<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> 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<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
return await playListsQuery
.OrderBy(playList => playList.CreatedAt)
.ThenBy(playList => playList.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -17,14 +17,9 @@ public class RemoveTrackFromPlayListCommandHandler(
{
public async ValueTask<ErrorOr<Success>> 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)
@@ -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<Guid> CoverTrackIds)
IReadOnlyList<Guid> 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<Guid>? coverTrackIds = null)
IReadOnlyList<Guid>? 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);
}
}
@@ -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<PlayList> Active(this IQueryable<PlayList> query) =>
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> 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)));
}
@@ -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)
{
@@ -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<ErrorOr<UploadIntent>> 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.");
@@ -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
{
/// <summary>
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
/// or not active, Forbidden when someone else owns it.
/// </summary>
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
this DbSet<T> 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);
}
}
@@ -1,25 +0,0 @@
using X.PagedList;
namespace Musify.Application.Shared;
public record PaginatedResponse<T>(
IReadOnlyCollection<T> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasNextPage,
bool HasPreviousPage)
{
public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
{
return new PaginatedResponse<T>(
pagedList.ToArray(),
pagedList.PageNumber,
pagedList.PageSize,
pagedList.PageCount,
pagedList.TotalItemCount,
pagedList.HasNextPage,
pagedList.HasPreviousPage);
}
}
@@ -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<T>(
IReadOnlyList<T> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasNextPage,
bool HasPreviousPage);
public static class PaginationExtensions
{
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
this IQueryable<T> 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<T>(
items,
pageNumber,
pageSize,
pageCount,
totalCount,
HasNextPage: pageNumber < pageCount,
HasPreviousPage: pageNumber > 1 && pageCount > 0);
}
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Shared;
public static class TextNormalizer
{
public static string Normalize(string value) => value.Trim().ToUpperInvariant();
}
@@ -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,
@@ -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<ErrorOr<Success>> 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}");
}
try
{
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
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}");
}
return new Success();
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
return Result.Success;
}
}
@@ -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<IEnumerable<TrackApplicationResponse>>;
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
public class GetListeningHistoryQueryHandler(
IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
public class GetListeningHistoryQueryHandler(IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
{
public const int ListSize = 15;
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> 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();
}
}
@@ -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<ErrorOr<TrackApplicationResponse>> 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;
}
}
@@ -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<ErrorOr<TrackCoverLocation>> 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);
}
@@ -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);
@@ -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<ErrorOr<TracksSearchResponse>>;
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
: IQueryHandler<GetTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> 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);
}
}
@@ -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<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> 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<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
return await tracksQuery
.OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -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;
@@ -0,0 +1,24 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses;
public static class TrackProjections
{
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> 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));
}
@@ -1,6 +0,0 @@
namespace Musify.Application.Tracks.Responses;
public record TrackSearchItemResponse(TrackApplicationResponse Track)
{
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
}
@@ -1,11 +0,0 @@
namespace Musify.Application.Tracks.Responses;
public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
@@ -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;
}
}
@@ -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<ListeningStatsResponse>;
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
public class GetListeningStatsQueryHandler(IDatabase database)
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
{
@@ -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;
}
}
@@ -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<ErrorOr<PaginatedResponse<UserSummaryResponse>>> 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<UserSummaryResponse>.FromPagedList(page);
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -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<ErrorOr<PaginatedResponse<UserSummaryResponse>>> 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<UserSummaryResponse>.FromPagedList(page);
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -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<ErrorOr<UserProfileResponse>>;
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<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
{
@@ -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()
@@ -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<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
: IQuery<PaginatedResponse<UserSummaryResponse>>;
public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
: IQueryHandler<GetUsersQuery, PaginatedResponse<UserSummaryResponse>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<UserSummaryResponse>> 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<UserSummaryResponse>.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);
}
}
@@ -0,0 +1,3 @@
namespace Musify.Application.Users.Responses;
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
@@ -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);
@@ -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<SyncUserCommandH
{
Id = request.Id,
Name = request.Name,
NormalizedName = request.Name.ToUpperInvariant(),
NormalizedName = TextNormalizer.Normalize(request.Name),
FirstName = request.FirstName,
SecondName = request.SecondName,
ProfilePictureUrl = request.ProfilePictureUrl
@@ -38,11 +39,11 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Provisioned user {UserId}", request.Id);
return new Success();
return Result.Success;
}
user.Name = request.Name;
user.NormalizedName = request.Name.ToUpperInvariant();
user.NormalizedName = TextNormalizer.Normalize(request.Name);
user.FirstName = request.FirstName;
user.SecondName = request.SecondName;
user.ProfilePictureUrl = request.ProfilePictureUrl;
@@ -51,6 +52,6 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
if (written > 0)
logger.LogInformation("Synced profile for user {UserId}", request.Id);
return new Success();
return Result.Success;
}
}
@@ -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;
}
}
@@ -2,7 +2,5 @@ namespace Musify.Domain.Abstractions;
public interface IOwnedEntity
{
public Guid Id { get; }
public long OwnerUserId { get; }
}
-2
View File
@@ -45,8 +45,6 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.6" />
<PackageVersion Include="SixLabors.ImageSharp" Version="2.1.13" />
<PackageVersion Include="X.PagedList" Version="10.6.0" />
<PackageVersion Include="X.PagedList.EF" Version="10.6.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
<!-- Test-only -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
@@ -25,7 +25,7 @@ public static class AlbumEndpoints
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetAlbums")
.WithSummary("Get Paginated Albums, Optionally Filtered By Title.")
.Produces<AlbumsSearchResponse>();
.Produces<PaginatedResponse<AlbumApplicationResponse>>();
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<IResult> 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<IResult> GetAlbumTracks(
@@ -1,5 +1,6 @@
using Mediator;
using Musify.Application.Genres;
using Musify.Application.Genres.Responses;
namespace Musify.Api.Endpoints;
@@ -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<IResult> ToggleTrackLike(
@@ -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<IResult> GetMixById(
@@ -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<IResult> GetPlayListTracks(
@@ -23,7 +23,7 @@ public static class TrackEndpoints
.AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
.Produces<TracksSearchResponse>()
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.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<IResult> GetTrackById(
@@ -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<IResult> GetUserById(
@@ -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<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
var body = await response.Content.ReadFromJsonAsync<PaginatedResponse<TrackApplicationResponse>>(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<TracksSearchResponse>(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<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags));
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -1,6 +1,7 @@
using Musify.Application.Genres;
using Musify.Domain.ValueObjects;
using Xunit;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Tests.Genres;
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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));
}
}
+11 -38
View File
@@ -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 */
@@ -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 ?? []
@@ -49,7 +49,7 @@
let albumMenu = $state<AlbumMenuState | null>(null);
let sfilter = $state<SearchFilter>('Todo');
type LocalTrack = (typeof data.tracks.items)[number]['track'];
type LocalTrack = (typeof data.tracks.items)[number];
let localItems = $state<LocalTrack[]>([]);
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 {
+1 -1
View File
@@ -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