Add shared pagination, projections and ownership helpers; simplify query and command handlers
This commit is contained in:
@@ -18,23 +18,13 @@ public class AddTrackToAlbumCommandHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.AsNoTracking()
|
if (album.IsError)
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
return album.Errors;
|
||||||
if (album == null)
|
|
||||||
return AppErrors.NotFound("Album", request.AlbumId);
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
var track = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||||
return AppErrors.Forbidden("Album", request.AlbumId);
|
if (track.IsError)
|
||||||
|
return track.Errors;
|
||||||
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 alreadyAdded = await database.AlbumHasTracks
|
var alreadyAdded = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
@@ -53,7 +54,7 @@ public class CreateAlbumCommandHandler(
|
|||||||
{
|
{
|
||||||
OwnerUserId = request.UserId,
|
OwnerUserId = request.UserId,
|
||||||
Title = title,
|
Title = title,
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
ReleaseYear = request.ReleaseYear,
|
ReleaseYear = request.ReleaseYear,
|
||||||
Pictures = EntityPictures.Pending(intent.ObjectName)
|
Pictures = EntityPictures.Pending(intent.ObjectName)
|
||||||
@@ -101,7 +102,7 @@ public class CreateAlbumCommandHandler(
|
|||||||
sizes.Medium,
|
sizes.Medium,
|
||||||
sizes.Large),
|
sizes.Large),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
@@ -20,20 +19,17 @@ public class DeleteAlbumCommandHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId && a.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
if (found.IsError)
|
||||||
if (album == null)
|
return found.Errors;
|
||||||
return AppErrors.NotFound("Album", request.AlbumId);
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
return AppErrors.Forbidden("Album", request.AlbumId);
|
|
||||||
|
|
||||||
|
var album = found.Value;
|
||||||
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
||||||
await database.SaveChangesAsync(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;
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -17,22 +17,13 @@ public class GetAlbumByIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active)
|
.Active()
|
||||||
.Select(a => new
|
.Where(a => a.Id == request.Id)
|
||||||
{
|
.SelectResponse()
|
||||||
Album = a,
|
|
||||||
TrackCount = a.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = a.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (album == null)
|
return album == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("Album", request.Id)
|
||||||
|
: album;
|
||||||
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -23,27 +21,18 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var albumExists = await database.Albums
|
var albumExists = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(album => album.Id == request.AlbumId && album.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
.Active()
|
||||||
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
if (!albumExists)
|
if (!albumExists)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
|
|
||||||
var tracksQuery = database.AlbumHasTracks
|
return await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(albumTrack => albumTrack.Track.Owner)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.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
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
|
.ThenBy(albumTrack => albumTrack.Id)
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.Select(albumTrack => albumTrack.Track)
|
||||||
|
.SelectResponse()
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,33 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using X.PagedList.EF;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
||||||
: IQuery<ErrorOr<AlbumsSearchResponse>>;
|
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class GetAlbumsQueryHandler(IDatabase database)
|
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
|
var albumsQuery = database.Albums
|
||||||
.AsNoTracking()
|
.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));
|
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
return await albumsQuery
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
.Select(album => new
|
.ThenBy(album => album.Id)
|
||||||
{
|
.SelectResponse()
|
||||||
Album = album,
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -22,32 +19,15 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database)
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||||
if (!userExists)
|
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()
|
.AsNoTracking()
|
||||||
.Where(album => album.OwnerUserId == request.UserId && album.LifeCycleStatus == LifeCycleStatus.Active);
|
.Active()
|
||||||
|
.Where(album => album.OwnerUserId == request.UserId)
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
.Select(album => new
|
.ThenBy(album => album.Id)
|
||||||
{
|
.SelectResponse()
|
||||||
Album = album,
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,51 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||||
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
|
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
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()
|
.AsNoTracking()
|
||||||
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
||||||
|
|
||||||
var ranked = await database.AlbumHasTracks
|
var ranked = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.Join(
|
.Join(
|
||||||
listenedTrackIds,
|
listens,
|
||||||
albumTrack => albumTrack.TrackId,
|
albumTrack => albumTrack.TrackId,
|
||||||
history => history.TrackId,
|
history => history.TrackId,
|
||||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
||||||
.GroupBy(entry => entry.AlbumId)
|
.GroupBy(entry => entry.AlbumId)
|
||||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
||||||
.OrderByDescending(entry => entry.LastListenedAt)
|
.OrderByDescending(entry => entry.LastListenedAt)
|
||||||
|
.ThenBy(entry => entry.AlbumId)
|
||||||
.Take(request.Limit)
|
.Take(request.Limit)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (ranked.Count == 0)
|
if (ranked.Count == 0)
|
||||||
return new List<AlbumApplicationResponse>();
|
return [];
|
||||||
|
|
||||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||||
|
|
||||||
var albums = await database.Albums
|
var albumsById = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => albumIds.Contains(album.Id) && album.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(album => albumIds.Contains(album.Id))
|
||||||
.Select(album => new
|
.SelectResponse()
|
||||||
{
|
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
||||||
Album = album,
|
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
|
||||||
|
|
||||||
return ranked
|
return ranked
|
||||||
.Where(entry => albums.ContainsKey(entry.AlbumId))
|
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
||||||
.Select(entry => albums[entry.AlbumId])
|
.Select(entry => albumsById[entry.AlbumId])
|
||||||
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,9 @@ public class RemoveTrackFromAlbumCommandHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.AsNoTracking()
|
if (album.IsError)
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
return album.Errors;
|
||||||
if (album == null)
|
|
||||||
return AppErrors.NotFound("Album", request.AlbumId);
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
return AppErrors.Forbidden("Album", request.AlbumId);
|
|
||||||
|
|
||||||
var albumTracks = await database.AlbumHasTracks
|
var albumTracks = await database.AlbumHasTracks
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
@@ -33,10 +28,7 @@ public class RemoveTrackFromAlbumCommandHandler(
|
|||||||
|
|
||||||
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
||||||
if (link == null)
|
if (link == null)
|
||||||
{
|
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
||||||
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.NotFound(description: "Track is not in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
database.AlbumHasTracks.Remove(link);
|
database.AlbumHasTracks.Remove(link);
|
||||||
|
|
||||||
@@ -48,6 +40,6 @@ public class RemoveTrackFromAlbumCommandHandler(
|
|||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
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,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds)
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = 4;
|
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||||
|
|
||||||
public static AlbumApplicationResponse FromEntity(
|
public static AlbumApplicationResponse FromEntity(
|
||||||
Album album,
|
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();
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
album.Title = title;
|
album.Title = title;
|
||||||
album.NormalizedTitle = title.ToUpperInvariant();
|
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||||
album.Description = request.Description;
|
album.Description = request.Description;
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
album.ReleaseYear = request.ReleaseYear;
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ public class UpdateAlbumCommandHandler(
|
|||||||
sizes.Medium,
|
sizes.Medium,
|
||||||
sizes.Large),
|
sizes.Large),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||||
<PackageReference Include="MimeMapping" />
|
<PackageReference Include="MimeMapping" />
|
||||||
<PackageReference Include="X.PagedList" />
|
|
||||||
<PackageReference Include="X.PagedList.EF" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Genres;
|
namespace Musify.Application.Genres;
|
||||||
|
|
||||||
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||||
|
|
||||||
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
|
||||||
|
|
||||||
public class GetAvailableGenresQueryHandler
|
public class GetAvailableGenresQueryHandler
|
||||||
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Genres;
|
namespace Musify.Application.Genres;
|
||||||
|
|
||||||
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||||
|
|
||||||
public record GenreResponse(Genre Genre, int TrackCount);
|
|
||||||
|
|
||||||
public class GetGenresQueryHandler(IDatabase database)
|
public class GetGenresQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
: 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 Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Likes;
|
namespace Musify.Application.Likes;
|
||||||
@@ -14,30 +11,20 @@ public record GetLikedTracksQuery(
|
|||||||
long UserId,
|
long UserId,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
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()
|
.AsNoTracking()
|
||||||
.Include(like => like.Track.Owner)
|
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.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
|
|
||||||
.OrderByDescending(like => like.CreatedAt)
|
.OrderByDescending(like => like.CreatedAt)
|
||||||
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
|
.ThenBy(like => like.Id)
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.Select(like => like.Track)
|
||||||
|
.SelectResponse()
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Likes;
|
namespace Musify.Application.Likes;
|
||||||
|
|
||||||
@@ -20,12 +21,9 @@ public class ToggleTrackLikeCommandHandler(
|
|||||||
{
|
{
|
||||||
var trackExists = await database.Tracks
|
var trackExists = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
if (!trackExists)
|
if (!trackExists)
|
||||||
{
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingLike = await database.TrackLikes
|
var existingLike = await database.TrackLikes
|
||||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class GenerateMixesForUserCommandHandler(
|
|||||||
if (libraryTrackIds.Count == 0)
|
if (libraryTrackIds.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
var historyTrackIds = await database.ListeningHistories
|
var historyTrackIds = await database.ListeningHistories
|
||||||
@@ -69,23 +69,15 @@ public class GenerateMixesForUserCommandHandler(
|
|||||||
if (drafts.Count == 0)
|
if (drafts.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
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);
|
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Mixes.Responses;
|
using Musify.Application.Mixes.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes;
|
namespace Musify.Application.Mixes;
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ public class GetMixByIdQueryHandler(IDatabase database)
|
|||||||
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
if (mix == null)
|
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 orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
@@ -7,18 +6,19 @@ using Musify.Application.Mixes.Responses;
|
|||||||
namespace Musify.Application.Mixes;
|
namespace Musify.Application.Mixes;
|
||||||
|
|
||||||
public record GetMixesByUserIdQuery(long UserId)
|
public record GetMixesByUserIdQuery(long UserId)
|
||||||
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
|
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
||||||
|
|
||||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
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
|
var mixes = await database.Mixes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(mix => mix.UserId == request.UserId)
|
.Where(mix => mix.UserId == request.UserId)
|
||||||
.Include(mix => mix.Items)
|
.Include(mix => mix.Items)
|
||||||
.OrderBy(mix => mix.Position)
|
.OrderBy(mix => mix.Position)
|
||||||
|
.ThenBy(mix => mix.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var orderedItemsByMix = mixes.ToDictionary(
|
var orderedItemsByMix = mixes.ToDictionary(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
|
|
||||||
@@ -18,18 +19,13 @@ public class AddTrackToPlayListCommandHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
.AsNoTracking()
|
if (playList.IsError)
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
return playList.Errors;
|
||||||
if (playList == null)
|
|
||||||
return AppErrors.NotFound("PlayList", request.PlayListId);
|
|
||||||
|
|
||||||
if (playList.OwnerUserId != request.UserId)
|
|
||||||
return AppErrors.Forbidden("PlayList", request.PlayListId);
|
|
||||||
|
|
||||||
var trackExists = await database.Tracks
|
var trackExists = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
if (!trackExists)
|
if (!trackExists)
|
||||||
return AppErrors.NotFound("Track", request.TrackId);
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
@@ -53,7 +54,7 @@ public class CreatePlayListCommandHandler(
|
|||||||
{
|
{
|
||||||
OwnerUserId = request.UserId,
|
OwnerUserId = request.UserId,
|
||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
NormalizedName = TextNormalizer.Normalize(request.Name),
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
Pictures = resolvedPictures.Pictures,
|
Pictures = resolvedPictures.Pictures,
|
||||||
Visibility = request.Visibility
|
Visibility = request.Visibility
|
||||||
@@ -119,7 +120,7 @@ public class CreatePlayListCommandHandler(
|
|||||||
sizes.Medium,
|
sizes.Medium,
|
||||||
sizes.Large),
|
sizes.Large),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
@@ -20,43 +19,18 @@ public class DeletePlayListCommandHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
if (playList == null)
|
if (found.IsError)
|
||||||
{
|
return found.Errors;
|
||||||
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 playList = found.Value;
|
||||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
try
|
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
||||||
{
|
|
||||||
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 database.SaveChangesAsync(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 Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
|
|
||||||
@@ -15,29 +15,16 @@ public class GetPlayListByIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entry = await database.PlayLists
|
var playList = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active)
|
.Active()
|
||||||
.Select(p => new
|
.VisibleTo(request.RequestingUserId)
|
||||||
{
|
.Where(p => p.Id == request.Id)
|
||||||
PlayList = p,
|
.SelectResponse()
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (entry == null)
|
return playList == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("PlayList", request.Id)
|
||||||
|
: playList;
|
||||||
if (entry.PlayList.Visibility == PlayListVisibility.Private
|
|
||||||
&& entry.PlayList.OwnerUserId != request.RequestingUserId)
|
|
||||||
{
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
|
|
||||||
@@ -22,33 +21,21 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListExists = await database.PlayLists
|
var playListVisible = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(
|
.Active()
|
||||||
p => p.Id == request.PlayListId
|
.VisibleTo(request.ViewerId)
|
||||||
&& p.LifeCycleStatus == LifeCycleStatus.Active
|
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
||||||
&& (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId),
|
if (!playListVisible)
|
||||||
cancellationToken);
|
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||||
if (!playListExists)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var tracksQuery = database.PlayListHasTracks
|
return await database.PlayListHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(plt => plt.Track.Owner)
|
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Include(plt => plt.Track.Tags)
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
.ThenBy(playListTrack => playListTrack.Id)
|
||||||
|
.Select(playListTrack => playListTrack.Track)
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
|
|
||||||
@@ -16,45 +12,29 @@ public record GetPlayListsByUserIdQuery(
|
|||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize,
|
int PageSize,
|
||||||
long? ViewerId = null)
|
long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
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
|
var playListsQuery = database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.OwnerUserId == request.UserId
|
.Active()
|
||||||
&& p.LifeCycleStatus == LifeCycleStatus.Active
|
.VisibleTo(request.ViewerId)
|
||||||
&& (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId));
|
.Where(playList => playList.OwnerUserId == request.UserId);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
|
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await playListsQuery.CountAsync(cancellationToken);
|
return await playListsQuery
|
||||||
|
.OrderBy(playList => playList.CreatedAt)
|
||||||
var pagedEntities = await playListsQuery
|
.ThenBy(playList => playList.Id)
|
||||||
.OrderBy(p => p.CreatedAt)
|
.SelectResponse()
|
||||||
.ThenBy(p => p.Id)
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,9 @@ public class RemoveTrackFromPlayListCommandHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
.AsNoTracking()
|
if (playList.IsError)
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
return playList.Errors;
|
||||||
if (playList == null)
|
|
||||||
return AppErrors.NotFound("PlayList", request.PlayListId);
|
|
||||||
|
|
||||||
if (playList.OwnerUserId != request.UserId)
|
|
||||||
return AppErrors.Forbidden("PlayList", request.PlayListId);
|
|
||||||
|
|
||||||
var playListTracks = await database.PlayListHasTracks
|
var playListTracks = await database.PlayListHasTracks
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId)
|
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Musify.Application.Serialization;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Responses;
|
namespace Musify.Application.PlayLists.Responses;
|
||||||
|
|
||||||
public record PlayListApplicationResponse(
|
public record PlayListApplicationResponse(
|
||||||
@@ -13,13 +16,18 @@ public record PlayListApplicationResponse(
|
|||||||
PlayListVisibility Visibility,
|
PlayListVisibility Visibility,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
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(
|
public static PlayListApplicationResponse FromEntity(
|
||||||
PlayList playList,
|
PlayList playList,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
IReadOnlyList<Guid>? coverTrackIds = null,
|
||||||
|
int trackCount = 0,
|
||||||
|
double durationSeconds = 0)
|
||||||
{
|
{
|
||||||
return new PlayListApplicationResponse(
|
return new PlayListApplicationResponse(
|
||||||
playList.Id,
|
playList.Id,
|
||||||
@@ -31,6 +39,9 @@ public record PlayListApplicationResponse(
|
|||||||
playList.Visibility,
|
playList.Visibility,
|
||||||
playList.CreatedAt,
|
playList.CreatedAt,
|
||||||
playList.UpdatedAt,
|
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))
|
if (!string.IsNullOrWhiteSpace(request.NewName))
|
||||||
{
|
{
|
||||||
playListEntity.Name = request.NewName.Trim();
|
playListEntity.Name = request.NewName.Trim();
|
||||||
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
|
playListEntity.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.NewDescription != null)
|
if (request.NewDescription != null)
|
||||||
@@ -123,7 +123,7 @@ public class UpdatePlayListCommandHandler(
|
|||||||
sizes.Medium,
|
sizes.Medium,
|
||||||
sizes.Large),
|
sizes.Large),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Musify.Application.Configuration;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Services;
|
namespace Musify.Application.Services;
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ public sealed class UploadIntentValidator(
|
|||||||
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
||||||
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
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(
|
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
||||||
@@ -44,20 +45,20 @@ public sealed class UploadIntentValidator(
|
|||||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
||||||
|
|
||||||
if (intent == null || intent.UserId != userId)
|
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)
|
if (intent.Purpose != purpose)
|
||||||
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
||||||
|
|
||||||
if (intent.IsConsumed)
|
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))
|
if (intent.IsExpiredAt(DateTime.UtcNow))
|
||||||
return Error.Validation(description: "Upload intent has expired.");
|
return Error.Validation(description: "Upload intent has expired.");
|
||||||
|
|
||||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
||||||
if (metadata == null)
|
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)
|
if (metadata.ContentLength > config.MaxUploadBytes)
|
||||||
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
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;
|
namespace Musify.Application.Shared;
|
||||||
|
|
||||||
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
||||||
{
|
{
|
||||||
public const int MaxPageSize = 100;
|
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 Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
@@ -82,8 +83,8 @@ public class CreateTrackCommandHandler(
|
|||||||
|
|
||||||
var trackEntity = new Track
|
var trackEntity = new Track
|
||||||
{
|
{
|
||||||
Title = request.Title,
|
Title = request.Title.Trim(),
|
||||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
||||||
OwnerUserId = user.Id,
|
OwnerUserId = user.Id,
|
||||||
Owner = user,
|
Owner = user,
|
||||||
IsExplicit = request.IsExplicit,
|
IsExplicit = request.IsExplicit,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
@@ -22,49 +21,21 @@ public class DeleteTrackCommandHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||||
if (track == null)
|
if (found.IsError)
|
||||||
{
|
return found.Errors;
|
||||||
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 track = found.Value;
|
||||||
if (track.Audio.IsInProgress)
|
if (track.Audio.IsInProgress)
|
||||||
{
|
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
try
|
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||||
{
|
|
||||||
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 database.SaveChangesAsync(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 Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>;
|
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetListeningHistoryQueryHandler(
|
public class GetListeningHistoryQueryHandler(IDatabase database)
|
||||||
IDatabase database)
|
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
||||||
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public const int ListSize = 15;
|
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
|
var recentTrackIds = await database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
||||||
.GroupBy(l => l.TrackId)
|
.GroupBy(l => l.TrackId)
|
||||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
||||||
|
.ThenBy(g => g.Key)
|
||||||
.Select(g => g.Key)
|
.Select(g => g.Key)
|
||||||
.Take(ListSize)
|
.Take(ListSize)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
var tracksById = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Owner)
|
.Active()
|
||||||
.Include(t => t.Tags)
|
.Where(t => recentTrackIds.Contains(t.Id))
|
||||||
.Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active)
|
.SelectResponse()
|
||||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|
||||||
return recentTrackIds
|
return recentTrackIds
|
||||||
.Where(id => tracksById.ContainsKey(id))
|
.Where(id => tracksById.ContainsKey(id))
|
||||||
.Select(id => tracksById[id])
|
.Select(id => tracksById[id])
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -15,16 +15,15 @@ public class GetTrackByIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entity = await database.Tracks.AsNoTracking()
|
var track = await database.Tracks
|
||||||
.Include(t => t.Owner)
|
.AsNoTracking()
|
||||||
.Include(t => t.Tags)
|
.Active()
|
||||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(t => t.Id == request.TrackId)
|
||||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
.SelectResponse()
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (entity == null)
|
return track == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("Track", request.TrackId)
|
||||||
|
: track;
|
||||||
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
using MimeMapping;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -20,12 +23,12 @@ public class GetTrackCoverQueryHandler(
|
|||||||
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var track = await database.Tracks.AsNoTracking()
|
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 })
|
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (track == null)
|
if (track == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("TrackCover", request.TrackId);
|
||||||
|
|
||||||
var routes = trackConfiguration.Routes;
|
var routes = trackConfiguration.Routes;
|
||||||
var size = PictureSizeParser.Parse(request.Size);
|
var size = PictureSizeParser.Parse(request.Size);
|
||||||
@@ -38,13 +41,9 @@ public class GetTrackCoverQueryHandler(
|
|||||||
var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name);
|
var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || key == null)
|
if (string.IsNullOrEmpty(name) || key == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("TrackCover", request.TrackId);
|
||||||
|
|
||||||
var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|
var contentType = MimeUtility.GetMimeMapping(name);
|
||||||
? "image/webp"
|
|
||||||
: name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "image/png"
|
|
||||||
: "image/jpeg";
|
|
||||||
|
|
||||||
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
|
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Musify.Application.Contracts;
|
|||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -32,13 +33,10 @@ public class GetTrackStreamQueryHandler(
|
|||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (track == null)
|
if (track == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
|
|
||||||
if (!track.Audio.IsProcessed)
|
if (!track.Audio.IsProcessed)
|
||||||
{
|
return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet.");
|
||||||
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.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,37 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
||||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetTracksQueryHandler(IDatabase database)
|
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()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Active();
|
||||||
.Include(ut => ut.Track.Tags)
|
|
||||||
.Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.Genre is { } genre)
|
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);
|
return await tracksQuery
|
||||||
|
.OrderByDescending(track => track.CreatedAt)
|
||||||
var pagedEntities = await tracksQuery
|
.ThenBy(track => track.Id)
|
||||||
.OrderByDescending(t => t.Track.CreatedAt)
|
.SelectResponse()
|
||||||
.ThenBy(t => t.Id)
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -22,29 +19,21 @@ public class GetTracksByUserIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tracksQuery = database.UserHasTracks
|
var tracksQuery = database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Active()
|
||||||
.Include(ut => ut.Track.Tags)
|
.Where(track => track.OwnerUserId == request.UserId);
|
||||||
.Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
return await tracksQuery
|
||||||
|
.OrderByDescending(track => track.CreatedAt)
|
||||||
var pagedEntities = await tracksQuery
|
.ThenBy(track => track.Id)
|
||||||
.OrderBy(t => t.Track.CreatedAt)
|
.SelectResponse()
|
||||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ public class RecordListeningProgressCommandHandler(IDatabase database)
|
|||||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
if (listen == null)
|
if (listen == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("Listen", request.ListenId);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
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()
|
.AsNoTracking()
|
||||||
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
|
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
|
||||||
if (!followedExists)
|
if (!followedExists)
|
||||||
{
|
return AppErrors.NotFound("User", request.FollowedId);
|
||||||
logger.LogInformation("User {FollowedId} not found", request.FollowedId);
|
|
||||||
return Error.NotFound(description: $"User {request.FollowedId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyFollowing = await database.UserFollows
|
var alreadyFollowing = await database.UserFollows
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
||||||
if (alreadyFollowing)
|
if (alreadyFollowing)
|
||||||
return new Success();
|
return Result.Success;
|
||||||
|
|
||||||
await database.UserFollows.AddAsync(new UserFollow
|
await database.UserFollows.AddAsync(new UserFollow
|
||||||
{
|
{
|
||||||
@@ -49,6 +46,6 @@ public class FollowUserCommandHandler(
|
|||||||
|
|
||||||
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
|
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Users.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
|
|
||||||
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
|
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
|
||||||
|
|
||||||
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
|
|
||||||
|
|
||||||
public class GetListeningStatsQueryHandler(IDatabase database)
|
public class GetListeningStatsQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
|
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
|
|
||||||
@@ -20,9 +21,8 @@ public class GetUserByIdQueryHandler(IDatabase database)
|
|||||||
.Select(u => UserApplicationResponse.FromEntity(u))
|
.Select(u => UserApplicationResponse.FromEntity(u))
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (user == null)
|
return user == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("User", request.Id)
|
||||||
|
: user;
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
|
|
||||||
@@ -17,20 +16,18 @@ public class GetUserFollowersQueryHandler(IDatabase database)
|
|||||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, 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))
|
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 viewerId = request.ViewerId;
|
||||||
|
|
||||||
var page = await followsQuery
|
return await database.UserFollows
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(f => f.FollowedId == request.UserId)
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.ThenBy(f => f.Id)
|
||||||
.Select(f => new UserSummaryResponse(
|
.Select(f => new UserSummaryResponse(
|
||||||
f.Follower.Id,
|
f.Follower.Id,
|
||||||
f.Follower.Name,
|
f.Follower.Name,
|
||||||
@@ -38,8 +35,6 @@ public class GetUserFollowersQueryHandler(IDatabase database)
|
|||||||
f.Follower.SecondName,
|
f.Follower.SecondName,
|
||||||
f.Follower.ProfilePictureUrl,
|
f.Follower.ProfilePictureUrl,
|
||||||
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
|
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
|
||||||
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
|
|
||||||
@@ -17,17 +16,15 @@ public class GetUserFollowingQueryHandler(IDatabase database)
|
|||||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, 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 viewerId = request.ViewerId;
|
||||||
|
|
||||||
var page = await followsQuery
|
return await database.UserFollows
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(f => f.FollowerId == request.UserId)
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.ThenBy(f => f.Id)
|
||||||
.Select(f => new UserSummaryResponse(
|
.Select(f => new UserSummaryResponse(
|
||||||
f.Followed.Id,
|
f.Followed.Id,
|
||||||
f.Followed.Name,
|
f.Followed.Name,
|
||||||
@@ -35,8 +32,6 @@ public class GetUserFollowingQueryHandler(IDatabase database)
|
|||||||
f.Followed.SecondName,
|
f.Followed.SecondName,
|
||||||
f.Followed.ProfilePictureUrl,
|
f.Followed.ProfilePictureUrl,
|
||||||
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
|
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
|
||||||
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Serialization;
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Users.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
|
|
||||||
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
|
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)
|
public class GetUserProfileQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
|
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
|
||||||
{
|
{
|
||||||
@@ -41,7 +29,7 @@ public class GetUserProfileQueryHandler(IDatabase database)
|
|||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
var followersCount = await database.UserFollows
|
var followersCount = await database.UserFollows
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -1,43 +1,40 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
|
|
||||||
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
|
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
: IQuery<PaginatedResponse<UserSummaryResponse>>;
|
||||||
|
|
||||||
public class GetUsersQueryHandler(IDatabase database)
|
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
|
var usersQuery = database.Users
|
||||||
.AsNoTracking();
|
.AsNoTracking();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.UsernameSearch))
|
if (!string.IsNullOrWhiteSpace(request.UsernameSearch))
|
||||||
{
|
{
|
||||||
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
|
var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch);
|
||||||
usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername));
|
usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await usersQuery.CountAsync(cancellationToken);
|
|
||||||
var viewerId = request.ViewerId;
|
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.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
@@ -29,7 +30,7 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
|
|||||||
{
|
{
|
||||||
Id = request.Id,
|
Id = request.Id,
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
NormalizedName = request.Name.ToUpperInvariant(),
|
NormalizedName = TextNormalizer.Normalize(request.Name),
|
||||||
FirstName = request.FirstName,
|
FirstName = request.FirstName,
|
||||||
SecondName = request.SecondName,
|
SecondName = request.SecondName,
|
||||||
ProfilePictureUrl = request.ProfilePictureUrl
|
ProfilePictureUrl = request.ProfilePictureUrl
|
||||||
@@ -38,11 +39,11 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
|
|||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
logger.LogInformation("Provisioned user {UserId}", request.Id);
|
logger.LogInformation("Provisioned user {UserId}", request.Id);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Name = request.Name;
|
user.Name = request.Name;
|
||||||
user.NormalizedName = request.Name.ToUpperInvariant();
|
user.NormalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
user.FirstName = request.FirstName;
|
user.FirstName = request.FirstName;
|
||||||
user.SecondName = request.SecondName;
|
user.SecondName = request.SecondName;
|
||||||
user.ProfilePictureUrl = request.ProfilePictureUrl;
|
user.ProfilePictureUrl = request.ProfilePictureUrl;
|
||||||
@@ -51,6 +52,6 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
|
|||||||
if (written > 0)
|
if (written > 0)
|
||||||
logger.LogInformation("Synced profile for user {UserId}", request.Id);
|
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);
|
.SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
|
||||||
|
|
||||||
if (follow == null)
|
if (follow == null)
|
||||||
return new Success();
|
return Result.Success;
|
||||||
|
|
||||||
database.UserFollows.Remove(follow);
|
database.UserFollows.Remove(follow);
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId);
|
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 interface IOwnedEntity
|
||||||
{
|
{
|
||||||
public Guid Id { get; }
|
|
||||||
|
|
||||||
public long OwnerUserId { get; }
|
public long OwnerUserId { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,6 @@
|
|||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.6" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.6" />
|
||||||
<PackageVersion Include="SixLabors.ImageSharp" Version="2.1.13" />
|
<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" />
|
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
<!-- Test-only -->
|
<!-- Test-only -->
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public static class AlbumEndpoints
|
|||||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetAlbums")
|
.WithName("GetAlbums")
|
||||||
.WithSummary("Get Paginated Albums, Optionally Filtered By Title.")
|
.WithSummary("Get Paginated Albums, Optionally Filtered By Title.")
|
||||||
.Produces<AlbumsSearchResponse>();
|
.Produces<PaginatedResponse<AlbumApplicationResponse>>();
|
||||||
|
|
||||||
group.MapGet("/{id}", GetAlbumById)
|
group.MapGet("/{id}", GetAlbumById)
|
||||||
.WithName("GetAlbumById")
|
.WithName("GetAlbumById")
|
||||||
@@ -129,7 +129,7 @@ public static class AlbumEndpoints
|
|||||||
[AsParameters] PageQuery page)
|
[AsParameters] PageQuery page)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken);
|
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(
|
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);
|
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetAlbumTracks(
|
private static async Task<IResult> GetAlbumTracks(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Application.Genres;
|
using Musify.Application.Genres;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
|
|
||||||
namespace Musify.Api.Endpoints;
|
namespace Musify.Api.Endpoints;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public static class LikeEndpoints
|
|||||||
[AsParameters] PageQuery page)
|
[AsParameters] PageQuery page)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken);
|
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(
|
private static async Task<IResult> ToggleTrackLike(
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public static class MixEndpoints
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken);
|
var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetMixById(
|
private static async Task<IResult> GetMixById(
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ public static class PlayListEndpoints
|
|||||||
[AsParameters] PageQuery page)
|
[AsParameters] PageQuery page)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
|
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(
|
private static async Task<IResult> GetPlayListTracks(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public static class TrackEndpoints
|
|||||||
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetTracks")
|
.WithName("GetTracks")
|
||||||
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
|
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
|
||||||
.Produces<TracksSearchResponse>()
|
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||||
.ProducesValidationProblem();
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
group.MapGet("/{id}", GetTrackById)
|
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);
|
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(
|
private static async Task<IResult> GetTrackById(
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ public static class UserEndpoints
|
|||||||
[AsParameters] PageQuery page)
|
[AsParameters] PageQuery page)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken);
|
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(
|
private static async Task<IResult> GetUserById(
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Musify.Api.Tests.TestSupport;
|
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.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
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);
|
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadFromJsonAsync<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
Assert.Contains(body!.Items, item => item.Title == title);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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);
|
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadFromJsonAsync<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
Assert.Contains(body!.Items, item => item.Title == title);
|
||||||
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
|
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"));
|
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);
|
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(2, result.TotalItemCount);
|
||||||
Assert.False(result.IsError);
|
Assert.All(result.Items, item => Assert.NotNull(item));
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
Assert.All(result.Value.Items, item => Assert.NotNull(item.Album));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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"));
|
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);
|
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
|
var item = Assert.Single(result.Items);
|
||||||
Assert.False(result.IsError);
|
Assert.Equal("Rock Classics", item.Title);
|
||||||
var item = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Rock Classics", item.Album.Title);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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"));
|
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);
|
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(2, result.Items.Count);
|
||||||
Assert.False(result.IsError);
|
Assert.True(result.HasNextPage);
|
||||||
Assert.Equal(2, result.Value.Items.Count);
|
|
||||||
Assert.True(result.Value.HasNextPage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-10
@@ -25,9 +25,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
|
|||||||
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
|
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(["Newer", "Older"], result.Select(album => album.Title));
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -37,9 +35,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync(owner);
|
await SeedAsync(owner);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Empty(result);
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Empty(result.Value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -53,9 +49,7 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
|
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(2, result.Value.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -75,6 +69,6 @@ public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
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.Application.Genres;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Genres;
|
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 });
|
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);
|
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title));
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -33,9 +31,7 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync(owner);
|
await SeedAsync(owner);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Empty(result.Items);
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Empty(result.Value.Items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -47,8 +43,6 @@ public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id });
|
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);
|
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Empty(result.Items);
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Empty(result.Value.Items);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync(owner, other, second, first, theirs);
|
await SeedAsync(owner, other, second, first, theirs);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(["First", "Second"], result.Select(mix => mix.Title));
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -31,8 +29,6 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync(owner);
|
await SeedAsync(owner);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Empty(result);
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Empty(result.Value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-12
@@ -21,9 +21,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
TestEntities.PlayList(other.Id, "Theirs"));
|
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);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
var playList = Assert.Single(result.Items);
|
||||||
Assert.False(result.IsError);
|
|
||||||
var playList = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Mine", playList.Name);
|
Assert.Equal("Mine", playList.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +35,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private));
|
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);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
|
||||||
|
var playList = Assert.Single(result.Items);
|
||||||
Assert.False(result.IsError);
|
|
||||||
var playList = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Public one", playList.Name);
|
Assert.Equal("Public one", playList.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +49,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private));
|
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);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(2, result.Items.Count);
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(2, result.Value.Items.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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"));
|
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);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
var playList = Assert.Single(result.Items);
|
||||||
Assert.False(result.IsError);
|
|
||||||
var playList = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Road Trip", playList.Name);
|
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 });
|
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);
|
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(2, result.TotalItemCount);
|
||||||
Assert.False(result.IsError);
|
Assert.Equal(2, result.Items.Count);
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
Assert.Equal(2, result.Value.Items.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -39,10 +37,8 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase
|
|||||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id });
|
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);
|
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
|
var item = Assert.Single(result.Items);
|
||||||
Assert.False(result.IsError);
|
Assert.Equal("Bohemian Rhapsody", item.Title);
|
||||||
var item = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -57,9 +53,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase
|
|||||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
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);
|
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
|
||||||
|
var item = Assert.Single(result.Items);
|
||||||
Assert.False(result.IsError);
|
Assert.Equal("Loud", item.Title);
|
||||||
var item = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Loud", item.Track.Title);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
|
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);
|
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(2, result.TotalItemCount);
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -26,9 +24,7 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase
|
|||||||
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
|
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);
|
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken);
|
||||||
|
var user = Assert.Single(result.Items);
|
||||||
Assert.False(result.IsError);
|
|
||||||
var user = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal(1, user.Id);
|
Assert.Equal(1, user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +36,6 @@ public sealed class GetUsersQueryHandlerTests : HandlerTestBase
|
|||||||
new UserFollow { FollowerId = 1, FollowedId = 2 });
|
new UserFollow { FollowerId = 1, FollowedId = 2 });
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
|
||||||
|
Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
-38
@@ -763,22 +763,6 @@ export interface components {
|
|||||||
expiresInSeconds: number | string;
|
expiresInSeconds: number | string;
|
||||||
uploadUrl: 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: {
|
AvailableGenreResponse: {
|
||||||
genre: components["schemas"]["Genre"];
|
genre: components["schemas"]["Genre"];
|
||||||
incompatibleWith: components["schemas"]["Genre"][];
|
incompatibleWith: components["schemas"]["Genre"][];
|
||||||
@@ -796,7 +780,7 @@ export interface components {
|
|||||||
description: null | string;
|
description: null | string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
pictureIntentId: null | string;
|
pictureIntentId: null | string;
|
||||||
visibility?: components["schemas"]["PlaylistVisibility"];
|
visibility?: components["schemas"]["PlayListVisibility"];
|
||||||
};
|
};
|
||||||
CreateTrackRequest: {
|
CreateTrackRequest: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -918,12 +902,17 @@ export interface components {
|
|||||||
smallImageKeyName: null | string;
|
smallImageKeyName: null | string;
|
||||||
mediumImageKeyName: null | string;
|
mediumImageKeyName: null | string;
|
||||||
largeImageKeyName: null | string;
|
largeImageKeyName: null | string;
|
||||||
visibility: components["schemas"]["PlaylistVisibility"];
|
visibility: components["schemas"]["PlayListVisibility"];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
coverTrackIds: string[];
|
coverTrackIds: string[];
|
||||||
|
ownerUserId: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
trackCount: number | string;
|
||||||
|
/** Format: double */
|
||||||
|
durationSeconds: number | string;
|
||||||
};
|
};
|
||||||
PlayListPictureUploadResponse: {
|
PlayListPictureUploadResponse: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -937,7 +926,7 @@ export interface components {
|
|||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
};
|
};
|
||||||
/** @enum {unknown} */
|
/** @enum {unknown} */
|
||||||
PlaylistVisibility: "Private" | "Public";
|
PlayListVisibility: "Private" | "Public";
|
||||||
/** @enum {unknown} */
|
/** @enum {unknown} */
|
||||||
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
|
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
|
||||||
RecordListeningProgressRequest: {
|
RecordListeningProgressRequest: {
|
||||||
@@ -988,22 +977,6 @@ export interface components {
|
|||||||
tags: components["schemas"]["Genre"][];
|
tags: components["schemas"]["Genre"][];
|
||||||
isExplicit: boolean;
|
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: {
|
TrackStreamResponse: {
|
||||||
manifestUrl: string;
|
manifestUrl: string;
|
||||||
ticket: string;
|
ticket: string;
|
||||||
@@ -1042,7 +1015,7 @@ export interface components {
|
|||||||
newDescription: null | string;
|
newDescription: null | string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
newPictureIntentId: null | string;
|
newPictureIntentId: null | string;
|
||||||
newVisibility?: null | components["schemas"]["PlaylistVisibility"];
|
newVisibility?: null | components["schemas"]["PlayListVisibility"];
|
||||||
};
|
};
|
||||||
UserApplicationResponse: {
|
UserApplicationResponse: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1106,7 +1079,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["AlbumsSearchResponse"];
|
"application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2158,7 +2131,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["TracksSearchResponse"];
|
"application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Bad Request */
|
/** @description Bad Request */
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
genre,
|
genre,
|
||||||
genres,
|
genres,
|
||||||
tracks,
|
tracks,
|
||||||
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
|
albums: albumsRes?.data?.items ?? [],
|
||||||
users: usersRes?.data?.items ?? [],
|
users: usersRes?.data?.items ?? [],
|
||||||
viewerId: user?.sub ?? null,
|
viewerId: user?.sub ?? null,
|
||||||
playlists: playlistsRes?.data?.items ?? []
|
playlists: playlistsRes?.data?.items ?? []
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||||
let sfilter = $state<SearchFilter>('Todo');
|
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 localItems = $state<LocalTrack[]>([]);
|
||||||
let localPage = $state(1);
|
let localPage = $state(1);
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
trackMenu.close();
|
trackMenu.close();
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
sfilter = data.genre ? 'Canciones' : 'Todo';
|
sfilter = data.genre ? 'Canciones' : 'Todo';
|
||||||
localItems = data.tracks.items.map((item) => item.track);
|
localItems = data.tracks.items;
|
||||||
localPage = Number(data.tracks.pageNumber);
|
localPage = Number(data.tracks.pageNumber);
|
||||||
hasMore = data.tracks.hasNextPage;
|
hasMore = data.tracks.hasNextPage;
|
||||||
});
|
});
|
||||||
@@ -177,11 +177,7 @@
|
|||||||
const res = await fetch(`/api/tracks?${params}`);
|
const res = await fetch(`/api/tracks?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as typeof data.tracks;
|
const next = (await res.json()) as typeof data.tracks;
|
||||||
localItems = appendUnique(
|
localItems = appendUnique(localItems, next.items, (track) => track.id);
|
||||||
localItems,
|
|
||||||
next.items.map((item) => item.track),
|
|
||||||
(track) => track.id
|
|
||||||
);
|
|
||||||
localPage = Number(next.pageNumber);
|
localPage = Number(next.pageNumber);
|
||||||
hasMore = next.hasNextPage;
|
hasMore = next.hasNextPage;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
recentlyPlayedPromise
|
recentlyPlayedPromise
|
||||||
]);
|
]);
|
||||||
const playlistItems = playlists.data?.items ?? [];
|
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 spotlightPlaylist = playlistItems[0] ?? null;
|
||||||
const spotlightTracksPromise = spotlightPlaylist
|
const spotlightTracksPromise = spotlightPlaylist
|
||||||
|
|||||||
Reference in New Issue
Block a user