Removes the last YouTube-specific routes (/youtube/*) and merges them
into the general track/album/playlist endpoints, keyed by a `source`
string validated against TrackSource. This mirrors the earlier track
search merge for albums and playlist-track additions:
- GetAlbums now merges local albums with live YouTube Music search
results, paginated via a youtubeContinuationToken, deduped against
already-materialized ExternalAlbum rows.
- New GET /albums/external/{source}/{externalId} and
GET /tracks/external/{source}/{externalId}/stream replace the
YouTube-only equivalents.
- POST /playlists/{id}/tracks replaces the separate
.../youtube-tracks endpoint, accepting an optional Source/ExternalId
pair alongside TrackId.
- AlbumSearchItemResponse/AlbumsSearchResponse follow the same
discriminated-union + trailing-HasNextPage-field pattern already
used for track search, so HasNextPage stays a required field in the
generated OpenAPI schema.
- Frontend routes renamed accordingly (/albums/youtube/[albumId] ->
/albums/external/[source]/[externalId], etc.) with a single
/api/thumbnail proxy replacing /api/youtube/thumbnail.
Musify.Api.Endpoints.YouTubeEndpoints and the standalone
SearchYouTubeAlbums query are removed as a result.
237 lines
8.8 KiB
C#
237 lines
8.8 KiB
C#
using Mediator;
|
|
using Musify.Api.Authentication;
|
|
using Musify.Api.DataTransferObjects.Albums;
|
|
using Musify.Api.Extensions;
|
|
using Musify.Api.Filters;
|
|
using Musify.Application.Albums;
|
|
using Musify.Application.Albums.Responses;
|
|
using Musify.Application.Shared;
|
|
using Musify.Application.Tracks.Responses;
|
|
using Musify.Application.YouTube;
|
|
using Musify.Domain.ValueObjects;
|
|
|
|
namespace Musify.Api.Endpoints;
|
|
|
|
public static class AlbumEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/albums")
|
|
.WithTags("Albums");
|
|
|
|
group.MapGet("/", GetAlbums)
|
|
.WithName("GetAlbums")
|
|
.WithSummary("Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given.")
|
|
.Produces<AlbumsSearchResponse>();
|
|
|
|
group.MapGet("/{id}", GetAlbumById)
|
|
.WithName("GetAlbumById")
|
|
.WithSummary("Get An Album By Id.")
|
|
.Produces<AlbumApplicationResponse>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/external/{source}/{externalId}", GetExternalAlbum)
|
|
.WithName("GetExternalAlbum")
|
|
.WithSummary("Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist.")
|
|
.Produces<YouTubeAlbumDetail>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/users/{userId}", GetAlbumsByUserId)
|
|
.WithName("GetAlbumsByUserId")
|
|
.WithSummary("Get Paginated Albums For A User.")
|
|
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/recent", GetRecentlyListenedAlbums)
|
|
.WithName("GetRecentlyListenedAlbums")
|
|
.WithSummary("Get The Albums The Current User Listened To Most Recently.")
|
|
.RequireAuthorization()
|
|
.Produces<IReadOnlyList<AlbumApplicationResponse>>()
|
|
.Produces(StatusCodes.Status401Unauthorized);
|
|
|
|
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
|
|
.WithName("GetAlbumTracks")
|
|
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
|
|
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/", CreateAlbum)
|
|
.WithName("CreateAlbum")
|
|
.WithSummary("Create A New Album.")
|
|
.AddEndpointFilter<ValidationFilter<CreateAlbumRequest>>()
|
|
.RequireAuthorization()
|
|
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
|
|
.ProducesValidationProblem()
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPut("/{albumId}", UpdateAlbum)
|
|
.WithName("UpdateAlbum")
|
|
.WithSummary("Update An Album.")
|
|
.AddEndpointFilter<ValidationFilter<UpdateAlbumRequest>>()
|
|
.RequireAuthorization()
|
|
.Produces<AlbumApplicationResponse>()
|
|
.ProducesValidationProblem()
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapDelete("/{albumId}", DeleteAlbum)
|
|
.WithName("DeleteAlbum")
|
|
.WithSummary("Delete An Album.")
|
|
.RequireAuthorization()
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum)
|
|
.WithName("AddTrackToAlbum")
|
|
.WithSummary("Add One Of Your Uploaded Tracks To An Album.")
|
|
.RequireAuthorization()
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
group.MapDelete("/{albumId}/tracks/{trackId}", RemoveTrackFromAlbum)
|
|
.WithName("RemoveTrackFromAlbum")
|
|
.WithSummary("Remove A Track From An Album.")
|
|
.RequireAuthorization()
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
return app;
|
|
}
|
|
|
|
private static async Task<IResult> GetAlbums(
|
|
IMediator mediator,
|
|
CancellationToken cancellationToken,
|
|
string? title,
|
|
int pageNumber = 1,
|
|
int pageSize = 10,
|
|
string? youtubeContinuationToken = null)
|
|
{
|
|
var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize, youtubeContinuationToken), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetAlbumById(
|
|
IMediator mediator,
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetExternalAlbum(
|
|
IMediator mediator,
|
|
string source,
|
|
string externalId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!Enum.TryParse<TrackSource>(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube)
|
|
return Results.NotFound($"Unknown or unsupported source '{source}'.");
|
|
|
|
var result = await mediator.Send(new GetYouTubeAlbumQuery(externalId), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetAlbumsByUserId(
|
|
IMediator mediator,
|
|
long userId,
|
|
CancellationToken cancellationToken,
|
|
int pageNumber = 1,
|
|
int pageSize = 10)
|
|
{
|
|
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetRecentlyListenedAlbums(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
CancellationToken cancellationToken,
|
|
int limit = 12)
|
|
{
|
|
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetAlbumTracks(
|
|
IMediator mediator,
|
|
Guid albumId,
|
|
CancellationToken cancellationToken,
|
|
int pageNumber = 1,
|
|
int pageSize = 50)
|
|
{
|
|
var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> CreateAlbum(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
CreateAlbumRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear),
|
|
cancellationToken);
|
|
|
|
return result.ToCreatedResult(album => $"/albums/{album.Id}");
|
|
}
|
|
|
|
private static async Task<IResult> UpdateAlbum(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid albumId,
|
|
UpdateAlbumRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new UpdateAlbumCommand(currentUser.RequiredId, albumId, request.NewTitle, request.NewDescription, request.NewReleaseYear),
|
|
cancellationToken);
|
|
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> DeleteAlbum(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid albumId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, albumId), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> AddTrackToAlbum(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid albumId,
|
|
Guid trackId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new AddTrackToAlbumCommand(currentUser.RequiredId, albumId, trackId),
|
|
cancellationToken);
|
|
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> RemoveTrackFromAlbum(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid albumId,
|
|
Guid trackId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new RemoveTrackFromAlbumCommand(currentUser.RequiredId, albumId, trackId),
|
|
cancellationToken);
|
|
|
|
return result.ToHttpResult();
|
|
}
|
|
}
|