Separate responsibilities: start-listening command, API-side JSON converters, bulk track endpoints, mix kinds

This commit is contained in:
2026-09-24 12:14:53 +02:00
parent 75d0c0817e
commit 0fa033faff
64 changed files with 2903 additions and 651 deletions
@@ -41,21 +41,6 @@ public class CreateTrackCommandHandler(
}
var distinctTags = request.Tags.Distinct().ToList();
if (distinctTags.Count == 0)
{
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
return Error.Validation(description: "At least one tag is required.");
}
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
if (tagConflicts.Count > 0)
{
var conflict = tagConflicts.First();
logger.LogWarning(
"Track {Title} was submitted with incompatible tags {First} and {Second}",
request.Title, conflict.First, conflict.Second);
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
}
if (request.PictureIntentId == request.AudioIntentId)
return Error.Validation(description: "Picture and audio must use different upload intents.");
@@ -105,11 +90,6 @@ public class CreateTrackCommandHandler(
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.UserHasTracks.AddAsync(new UserHasTrack
{
UserId = request.UserId,
TrackId = trackEntity.Id
}, cancellationToken);
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
await eventBus.PublishAsync(
@@ -17,9 +17,6 @@ public class RecordListeningProgressCommandHandler(IDatabase database)
{
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
{
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
return Error.Validation(description: "playedSeconds must be a non-negative number.");
var listen = await database.ListeningHistories
.Include(l => l.Track)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
@@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
@@ -14,7 +12,7 @@ public record TrackApplicationResponse(
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
{
@@ -5,24 +5,25 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks;
/// <summary>Starts a listen: records it for signed-in users and issues the stream ticket.</summary>
public record StartListeningCommand(Guid TrackId, long? UserId)
: ICommand<ErrorOr<TrackStreamResponse>>;
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
: IQuery<ErrorOr<TrackStreamResponse>>;
public class GetTrackStreamQueryHandler(
public class StartListeningCommandHandler(
IDatabase database,
TrackStreamIssuer streamIssuer,
PlaybackConfiguration playbackConfiguration,
ILogger<GetTrackStreamQueryHandler> logger)
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
ILogger<StartListeningCommandHandler> logger)
: ICommandHandler<StartListeningCommand, ErrorOr<TrackStreamResponse>>
{
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(StartListeningCommand request, CancellationToken cancellationToken)
{
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
@@ -38,10 +39,17 @@ public class GetTrackStreamQueryHandler(
if (!track.Audio.IsProcessed)
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);
Guid? listenId = null;
if (request.UserId is { } userId)
{
var listen = new ListeningHistory { UserId = userId, TrackId = track.Id };
await database.ListeningHistories.AddAsync(listen, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
listenId = listen.Id;
}
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)");
return response;
return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId);
}
}