Add shared pagination, projections and ownership helpers; simplify query and command handlers
This commit is contained in:
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
@@ -82,8 +83,8 @@ public class CreateTrackCommandHandler(
|
||||
|
||||
var trackEntity = new Track
|
||||
{
|
||||
Title = request.Title,
|
||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||
Title = request.Title.Trim(),
|
||||
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
||||
OwnerUserId = user.Id,
|
||||
Owner = user,
|
||||
IsExplicit = request.IsExplicit,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
@@ -22,49 +21,21 @@ public class DeleteTrackCommandHandler(
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (track == null)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
if (track.OwnerUserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
||||
return AppErrors.Forbidden("Track", request.TrackId);
|
||||
}
|
||||
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var track = found.Value;
|
||||
if (track.Audio.IsInProgress)
|
||||
{
|
||||
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
|
||||
return Error.Conflict(description: "Track is currently being processed and cannot be deleted");
|
||||
}
|
||||
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
||||
|
||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new DeleteTrackEvent(track.Id, request.UserId),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
|
||||
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
|
||||
}
|
||||
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId);
|
||||
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
|
||||
}
|
||||
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
|
||||
|
||||
return new Success();
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,40 +2,38 @@ using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>;
|
||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
||||
|
||||
public class GetListeningHistoryQueryHandler(
|
||||
IDatabase database)
|
||||
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
|
||||
public class GetListeningHistoryQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
||||
{
|
||||
public const int ListSize = 15;
|
||||
|
||||
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var recentTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
||||
.GroupBy(l => l.TrackId)
|
||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
||||
.ThenBy(g => g.Key)
|
||||
.Select(g => g.Key)
|
||||
.Take(ListSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Owner)
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.Active()
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
return recentTrackIds
|
||||
.Where(id => tracksById.ContainsKey(id))
|
||||
.Select(id => tracksById[id])
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
@@ -15,16 +15,15 @@ public class GetTrackByIdQueryHandler(IDatabase database)
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await database.Tracks.AsNoTracking()
|
||||
.Include(t => t.Owner)
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
||||
var track = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.SelectResponse()
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
return Error.NotFound();
|
||||
|
||||
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
|
||||
return track == null
|
||||
? AppErrors.NotFound("Track", request.TrackId)
|
||||
: track;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using MimeMapping;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
@@ -20,12 +23,12 @@ public class GetTrackCoverQueryHandler(
|
||||
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (track == null)
|
||||
return Error.NotFound();
|
||||
return AppErrors.NotFound("TrackCover", request.TrackId);
|
||||
|
||||
var routes = trackConfiguration.Routes;
|
||||
var size = PictureSizeParser.Parse(request.Size);
|
||||
@@ -38,13 +41,9 @@ public class GetTrackCoverQueryHandler(
|
||||
var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name);
|
||||
|
||||
if (string.IsNullOrEmpty(name) || key == null)
|
||||
return Error.NotFound();
|
||||
return AppErrors.NotFound("TrackCover", request.TrackId);
|
||||
|
||||
var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|
||||
? "image/webp"
|
||||
: name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
||||
? "image/png"
|
||||
: "image/jpeg";
|
||||
var contentType = MimeUtility.GetMimeMapping(name);
|
||||
|
||||
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
@@ -32,13 +33,10 @@ public class GetTrackStreamQueryHandler(
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (track == null)
|
||||
return Error.NotFound();
|
||||
return AppErrors.NotFound("Track", request.TrackId);
|
||||
|
||||
if (!track.Audio.IsProcessed)
|
||||
{
|
||||
logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
|
||||
return Error.Conflict(description: "Track audio is not available for streaming yet.");
|
||||
}
|
||||
return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet.");
|
||||
|
||||
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
||||
|
||||
|
||||
@@ -1,55 +1,37 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using X.PagedList.EF;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
|
||||
: IQueryHandler<GetTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tracksQuery = database.UserHasTracks
|
||||
var tracksQuery = database.Tracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ut.Track.Owner)
|
||||
.Include(ut => ut.Track.Tags)
|
||||
.Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
.Active();
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
|
||||
if (request.Genre is { } genre)
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
|
||||
tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre));
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pagedEntities = await tracksQuery
|
||||
.OrderByDescending(t => t.Track.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var items = pagedEntities
|
||||
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
|
||||
.ToList();
|
||||
|
||||
return new TracksSearchResponse(
|
||||
items,
|
||||
pagedEntities.PageNumber,
|
||||
pagedEntities.PageSize,
|
||||
pagedEntities.PageCount,
|
||||
pagedEntities.TotalItemCount,
|
||||
pagedEntities.HasPreviousPage,
|
||||
pagedEntities.HasNextPage);
|
||||
return await tracksQuery
|
||||
.OrderByDescending(track => track.CreatedAt)
|
||||
.ThenBy(track => track.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
@@ -22,29 +19,21 @@ public class GetTracksByUserIdQueryHandler(IDatabase database)
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tracksQuery = database.UserHasTracks
|
||||
var tracksQuery = database.Tracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ut.Track.Owner)
|
||||
.Include(ut => ut.Track.Tags)
|
||||
.Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
.Active()
|
||||
.Where(track => track.OwnerUserId == request.UserId);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pagedEntities = await tracksQuery
|
||||
.OrderBy(t => t.Track.CreatedAt)
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
||||
return await tracksQuery
|
||||
.OrderByDescending(track => track.CreatedAt)
|
||||
.ThenBy(track => track.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
@@ -24,7 +25,7 @@ public class RecordListeningProgressCommandHandler(IDatabase database)
|
||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
||||
|
||||
if (listen == null)
|
||||
return Error.NotFound();
|
||||
return AppErrors.NotFound("Listen", request.ListenId);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses;
|
||||
|
||||
public static class TrackProjections
|
||||
{
|
||||
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
|
||||
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
|
||||
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
|
||||
query.Select(track => new TrackApplicationResponse(
|
||||
track.Id,
|
||||
track.Title,
|
||||
track.Owner.Name,
|
||||
track.Audio.TranscodeStatus,
|
||||
track.DurationSeconds,
|
||||
track.ListeningHistories.Count(listen => listen.IsCounted),
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId,
|
||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||
track.IsExplicit));
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Musify.Application.Tracks.Responses;
|
||||
|
||||
public record TrackSearchItemResponse(TrackApplicationResponse Track)
|
||||
{
|
||||
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Musify.Application.Tracks.Responses;
|
||||
|
||||
|
||||
public record TracksSearchResponse(
|
||||
IReadOnlyList<TrackSearchItemResponse> Items,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int TotalItemCount,
|
||||
bool HasPreviousPage,
|
||||
bool HasNextPage);
|
||||
Reference in New Issue
Block a user