CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
Discover used a hardcoded list of genre tiles that searched track titles, and the upload form kept its own copy of the genre list and compatibility rules. Both now come from the backend. Backend: - GET /genres returns genres with at least one track and their counts. - GET /genres/available returns every assignable genre with the genres it cannot be combined with, so the rules live only in GenreCompatibility. - GET /tracks accepts ?genre= (case-insensitive, 400 on unknown values). web-player: - Discover tiles are built from /genres; each opens /explore?genre=X, a song list filtered by that tag. Unknown genres fall back to a label derived from their name. - The upload form loads /genres/available, disables incompatible genres as you pick, and the server validates before reserving upload URLs. - lib/data/trackGenres.ts is removed; its helpers merge into lib/data/genres.ts. - Regenerate the typed API client.
224 lines
8.3 KiB
C#
224 lines
8.3 KiB
C#
using Mediator;
|
|
using Musify.Domain.ValueObjects;
|
|
using Musify.Application.Tracks;
|
|
using Musify.Application.Tracks.Responses;
|
|
using Musify.Application.Shared;
|
|
using Musify.Application.Contracts;
|
|
using Musify.Api.Authentication;
|
|
using Musify.Api.DataTransferObjects.Tracks;
|
|
using Musify.Api.Extensions;
|
|
using Musify.Api.Filters;
|
|
|
|
namespace Musify.Api.Endpoints;
|
|
|
|
public static class TrackEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/tracks")
|
|
.WithTags("Tracks");
|
|
|
|
group.MapGet("/", GetTracks)
|
|
.WithName("GetTracks")
|
|
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
|
|
.Produces<TracksSearchResponse>()
|
|
.ProducesValidationProblem();
|
|
|
|
group.MapGet("/{id}", GetTrackById)
|
|
.WithName("GetTrackById")
|
|
.WithSummary("Get A Track By Id.")
|
|
.Produces<TrackApplicationResponse>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{id}/cover", GetTrackCover)
|
|
.WithName("GetTrackCover")
|
|
.WithSummary("Get A Track Cover Image.")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{id}/stream", GetTrackStream)
|
|
.WithName("GetTrackStream")
|
|
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It.")
|
|
.Produces<TrackStreamResponse>()
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPut("/listens/{listenId}/progress", RecordListeningProgress)
|
|
.WithName("RecordListeningProgress")
|
|
.WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.")
|
|
.RequireAuthorization()
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/users/{userId}", GetTracksByUserId)
|
|
.WithName("GetTracksByUserId")
|
|
.WithSummary("Get Paginated Tracks For A User.")
|
|
.Produces<PaginatedResponse<TrackApplicationResponse>>();
|
|
|
|
group.MapPost("/", CreateTrack)
|
|
.WithName("CreateTrack")
|
|
.WithSummary("Create A New Track.")
|
|
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
|
|
.RequireAuthorization()
|
|
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
|
|
.ProducesValidationProblem()
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
group.MapDelete("/{trackId}", DeleteTrack)
|
|
.WithName("DeleteTrack")
|
|
.WithSummary("Delete A Track.")
|
|
.RequireAuthorization()
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
group.MapPost("/upload-urls", RequestTrackUploadUrls)
|
|
.WithName("RequestTrackUploadUrls")
|
|
.WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.")
|
|
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
|
|
.RequireAuthorization()
|
|
.Produces<TrackUploadUrlsResponse>()
|
|
.ProducesValidationProblem()
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
return app;
|
|
}
|
|
|
|
private static async Task<IResult> GetTracks(
|
|
IMediator mediator,
|
|
CancellationToken cancellationToken,
|
|
string? name,
|
|
string? genre,
|
|
int pageNumber = 1,
|
|
int pageSize = 10)
|
|
{
|
|
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, pageNumber, pageSize, parsedGenre), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetTrackById(
|
|
IMediator mediator,
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetTrackCover(
|
|
IMediator mediator,
|
|
IStorageService storageService,
|
|
HttpResponse response,
|
|
Guid id,
|
|
CancellationToken cancellationToken,
|
|
string size = "medium")
|
|
{
|
|
var result = await mediator.Send(new GetTrackCoverQuery(id, size), cancellationToken);
|
|
if (result.IsError)
|
|
return Results.NotFound();
|
|
|
|
var location = result.Value;
|
|
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
|
return Results.Stream(stream, location.ContentType);
|
|
}
|
|
|
|
private static async Task<IResult> GetTrackStream(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> RecordListeningProgress(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid listenId,
|
|
RecordListeningProgressRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds),
|
|
cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetTracksByUserId(
|
|
IMediator mediator,
|
|
long userId,
|
|
CancellationToken cancellationToken,
|
|
string? name,
|
|
int pageNumber = 1,
|
|
int pageSize = 10)
|
|
{
|
|
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> CreateTrack(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
CreateTrackRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags),
|
|
cancellationToken);
|
|
|
|
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
|
}
|
|
|
|
private static async Task<IResult> DeleteTrack(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid trackId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, trackId), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> RequestTrackUploadUrls(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
RequestTrackUploadUrlsRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new RequestTrackUploadUrlsCommand(
|
|
currentUser.RequiredId,
|
|
request.PictureFileType,
|
|
request.PictureContentType,
|
|
request.AudioFileType,
|
|
request.AudioContentType,
|
|
request.ExpectedPictureSizeBytes,
|
|
request.ExpectedAudioSizeBytes),
|
|
cancellationToken);
|
|
|
|
return result.ToHttpResult();
|
|
}
|
|
}
|