Add track status filter and counts, album track ids and bulk track deletion

GET /tracks/users/{userId} accepts repeated status values and returns the
albums each track belongs to. GET /tracks/users/{userId}/status-counts,
GET /albums/{id}/tracks/ids and DELETE /tracks with a list of track ids
are new.
This commit is contained in:
2026-09-25 01:14:59 +02:00
parent 9928840577
commit d839a72700
18 changed files with 773 additions and 8 deletions
@@ -0,0 +1,60 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record DeleteTracksCommand(long UserId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<DeleteTracksResponse>>;
public class DeleteTracksCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteTracksCommandHandler> logger)
: ICommandHandler<DeleteTracksCommand, ErrorOr<DeleteTracksResponse>>
{
public async ValueTask<ErrorOr<DeleteTracksResponse>> Handle(DeleteTracksCommand request, CancellationToken cancellationToken)
{
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.ToListAsync(cancellationToken);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var deleted = new List<Guid>();
var failed = new List<Guid>();
foreach (var trackId in requestedIds)
{
var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId);
if (track == null || track.Audio.IsInProgress)
{
failed.Add(trackId);
continue;
}
track.LifeCycleStatus = LifeCycleStatus.Removing;
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
deleted.Add(trackId);
}
if (deleted.Count > 0)
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Marked {Count} tracks of user {UserId} as removing", deleted.Count, request.UserId);
}
return new DeleteTracksResponse(deleted, failed);
}
}
}
@@ -0,0 +1,35 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record GetTrackStatusCountsQuery(long UserId)
: IQuery<TrackStatusCountsResponse>;
public class GetTrackStatusCountsQueryHandler(IDatabase database)
: IQueryHandler<GetTrackStatusCountsQuery, TrackStatusCountsResponse>
{
public async ValueTask<TrackStatusCountsResponse> Handle(GetTrackStatusCountsQuery request, CancellationToken cancellationToken)
{
var counts = await database.Tracks
.AsNoTracking()
.Active()
.Where(track => track.OwnerUserId == request.UserId)
.GroupBy(track => track.Audio.TranscodeStatus)
.Select(group => new { Status = group.Key, Count = group.Count() })
.ToListAsync(cancellationToken);
int CountOf(ProcessingStatus status) => counts.Where(c => c.Status == status).Sum(c => c.Count);
return new TrackStatusCountsResponse(
counts.Sum(c => c.Count),
CountOf(ProcessingStatus.Pending),
CountOf(ProcessingStatus.Processing),
CountOf(ProcessingStatus.Completed),
CountOf(ProcessingStatus.Failed));
}
}
}
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
@@ -11,13 +12,14 @@ namespace Musify.Application.Tracks
long UserId,
string? Name,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
int PageSize,
IReadOnlyList<ProcessingStatus>? Statuses = null)
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
public class GetTracksByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.Tracks
.AsNoTracking()
@@ -30,10 +32,16 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
}
if (request.Statuses is { Count: > 0 } statuses)
{
var statusList = statuses.Distinct().ToList();
tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus));
}
return await tracksQuery
.OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id)
.SelectResponse()
.SelectUserResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Tracks.Responses
{
public record DeleteTracksResponse(
IReadOnlyList<Guid> DeletedTrackIds,
IReadOnlyList<Guid> FailedTrackIds);
}
@@ -21,5 +21,25 @@ namespace Musify.Application.Tracks.Responses
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit));
public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query) =>
query.Select(track => new UserTrackApplicationResponse(
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,
track.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.AddedAt)
.ThenBy(albumTrack => albumTrack.AlbumId)
.Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title))
.ToList()));
}
}
@@ -0,0 +1,9 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackStatusCountsResponse(
int Total,
int Pending,
int Processing,
int Completed,
int Failed);
}
@@ -0,0 +1,20 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses
{
public record TrackAlbumReference(Guid Id, string Title);
public record UserTrackApplicationResponse(
Guid Id,
string Title,
string? Artist,
ProcessingStatus AudioStatus,
double Duration,
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit,
IReadOnlyList<TrackAlbumReference> Albums);
}