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
@@ -0,0 +1,3 @@
namespace Musify.Api.DataTransferObjects;
public record AddTracksRequest(IReadOnlyList<Guid> TrackIds);
@@ -1,3 +0,0 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record AddPlayListTrackRequest(Guid TrackId);
@@ -1,5 +1,6 @@
using Mediator;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Extensions;
using Musify.Api.Filters;
@@ -102,11 +103,13 @@ public static class AlbumEndpoints
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum)
.WithName("AddTrackToAlbum")
.WithSummary("Add One Of Your Uploaded Tracks To An Album.")
group.MapPost("/{id}/tracks", AddAlbumTracks)
.WithName("AddAlbumTracks")
.WithSummary("Add Your Uploaded Tracks To An Album, Skipping The Ones It Already Has.")
.AddEndpointFilter<ValidationFilter<AddTracksRequest>>()
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
@@ -242,15 +245,15 @@ public static class AlbumEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> AddTrackToAlbum(
private static async Task<IResult> AddAlbumTracks(
IMediator mediator,
CurrentUser currentUser,
Guid albumId,
Guid trackId,
Guid id,
AddTracksRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddTrackToAlbumCommand(currentUser.RequiredId, albumId, trackId),
new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds),
cancellationToken);
return result.ToHttpResult();
@@ -1,5 +1,6 @@
using Mediator;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.Extensions;
using Musify.Api.Filters;
@@ -10,7 +11,6 @@ using Musify.Application.PlayLists;
using Musify.Application.PlayLists.Responses;
using Musify.Api.Models;
using Musify.Application.Shared;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
namespace Musify.Api.Endpoints;
@@ -87,11 +87,13 @@ public static class PlayListEndpoints
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{playlistId}/tracks", AddPlayListTrack)
.WithName("AddPlayListTrack")
.WithSummary("Add One Of Your Tracks To A PlayList.")
group.MapPost("/{id}/tracks", AddPlayListTracks)
.WithName("AddPlayListTracks")
.WithSummary("Add Tracks To A PlayList, Skipping The Ones It Already Has.")
.AddEndpointFilter<ValidationFilter<AddTracksRequest>>()
.RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status204NoContent)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
@@ -203,21 +205,18 @@ public static class PlayListEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> AddPlayListTrack(
private static async Task<IResult> AddPlayListTracks(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
AddPlayListTrackRequest request,
Guid id,
AddTracksRequest request,
CancellationToken cancellationToken)
{
var addResult = await mediator.Send(
new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId),
var result = await mediator.Send(
new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds),
cancellationToken);
if (addResult.IsError)
return addResult.ToHttpResult();
var trackResult = await mediator.Send(new GetTrackByIdQuery(request.TrackId), cancellationToken);
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
return result.ToHttpResult();
}
private static async Task<IResult> RemoveTrackFromPlayList(
@@ -9,7 +9,6 @@ using Musify.Api.Models;
using Musify.Application.Shared;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Api.Endpoints;
@@ -47,6 +46,7 @@ public static class TrackEndpoints
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/listens/{listenId}/progress", RecordListeningProgress)
.AddEndpointFilter<ValidationFilter<RecordListeningProgressRequest>>()
.WithName("RecordListeningProgress")
.WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.")
.RequireAuthorization()
@@ -99,24 +99,10 @@ public static class TrackEndpoints
IMediator mediator,
CancellationToken cancellationToken,
string? name,
string? genre,
GenreParameter? genre,
[AsParameters] PageQuery page)
{
Genre? parsedGenre = null;
if (!string.IsNullOrWhiteSpace(genre))
{
if (!Enum.TryParse<Genre>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(genre)] = [$"'{genre}' is not a valid genre."]
});
}
parsedGenre = value;
}
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, genre?.Value), cancellationToken);
return Results.Ok(result);
}
@@ -146,7 +132,7 @@ public static class TrackEndpoints
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken);
var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
@@ -0,0 +1,19 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.Models;
/// <summary>Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400.</summary>
public readonly record struct GenreParameter(Genre Value)
{
public static bool TryParse(string? value, out GenreParameter result)
{
if (Enum.TryParse<Genre>(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre))
{
result = new GenreParameter(genre);
return true;
}
result = default;
return false;
}
}
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Musify.Api.Authentication;
using Musify.Application.Serialization;
namespace Musify.Api.OpenApi;
@@ -41,10 +40,16 @@ public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> op
options.AddSchemaTransformer((schema, context, _) =>
{
if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter)
// Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53).
// Request DTOs only carry byte sizes, which may be sent as numbers or strings.
if (context.JsonPropertyInfo is { } property
&& (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?)))
{
schema.Type = JsonSchemaType.String;
schema.Format = null;
var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true;
schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String;
if (property.PropertyType == typeof(long?))
schema.Type |= JsonSchemaType.Null;
schema.Format = isRequest ? "int64" : null;
}
return Task.CompletedTask;
+6 -1
View File
@@ -1,13 +1,18 @@
using System.Text.Json.Serialization;
using Musify.Api.Endpoints;
using Musify.Api.Extensions;
using Musify.Api.Serialization;
using Musify.Infrastructure.Observability;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.SerializerOptions.Converters.Add(new LongAsStringConverter());
options.SerializerOptions.Converters.Add(new NullableLongAsStringConverter());
});
const string devCorsPolicy = "dev-cors";
@@ -0,0 +1,37 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Musify.Api.Serialization;
public sealed class LongAsStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType == JsonTokenType.String
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
{
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return reader.TokenType == JsonTokenType.String
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
}
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
else
writer.WriteNullValue();
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
using Musify.Api.DataTransferObjects;
namespace Musify.Api.Validators;
public sealed class AddTracksRequestValidator : AbstractValidator<AddTracksRequest>
{
public const int MaxTracksPerRequest = 500;
public AddTracksRequestValidator()
{
RuleFor(x => x.TrackIds)
.NotEmpty()
.Must(ids => ids.Count <= MaxTracksPerRequest)
.WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once.");
RuleForEach(x => x.TrackIds)
.NotEmpty();
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks;
public sealed class RecordListeningProgressRequestValidator : AbstractValidator<RecordListeningProgressRequest>
{
public RecordListeningProgressRequestValidator()
{
RuleFor(x => x.PlayedSeconds)
.Must(seconds => double.IsFinite(seconds) && seconds >= 0)
.WithMessage("'Played Seconds' must be a non-negative number.");
}
}