From 461bae1b01422494cfae454b92d16579091c8192 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 15 Jul 2026 16:09:40 +0200 Subject: [PATCH] feat(backend): add playlist cover endpoint and relax description validation Adds GET /playlists/{id}/cover (mirrors the track cover endpoint) so uploaded playlist pictures can actually be served. Also drops the NotEmpty rule on playlist description, which rejected the blank description the frontend treats as optional; CreatePlayList now falls back to the domain's default text instead. --- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 31 +++++++++++- .../CreatePlayListRequestValidator.cs | 1 - .../UpdatePlayListRequestValidator.cs | 1 - .../Musify.Application.csproj | 1 + .../PlayLists/GetPlayListCover.cs | 49 +++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 backend/Musify.Application/PlayLists/GetPlayListCover.cs diff --git a/backend/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Musify.Api/Endpoints/PlayListEndpoints.cs index 134b7a9..37abeb3 100644 --- a/backend/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -3,6 +3,7 @@ using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; @@ -39,6 +40,12 @@ public static class PlayListEndpoints .Produces>() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/cover", GetPlayListCover) + .WithName("GetPlayListCover") + .WithSummary("Get A PlayList Cover Image.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + group.MapPost("/", CreatePlayList) .WithName("CreatePlayList") .WithSummary("Create A New PlayList.") @@ -139,14 +146,36 @@ public static class PlayListEndpoints return result.ToHttpResult(); } + private static async Task GetPlayListCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + var result = await mediator.Send(new GetPlayListCoverQuery(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 CreatePlayList( IMediator mediator, CurrentUser currentUser, CreatePlayListRequest request, CancellationToken cancellationToken) { + var description = string.IsNullOrWhiteSpace(request.Description) + ? "No description was provided." + : request.Description; + var result = await mediator.Send( - new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), + new CreatePlayListCommand(currentUser.RequiredId, request.Name, description, request.PictureIntentId), cancellationToken); return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); diff --git a/backend/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index 0664e82..b0003b7 100644 --- a/backend/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -12,7 +12,6 @@ public sealed class CreatePlayListRequestValidator : AbstractValidator x.Description) - .NotEmpty() .MaximumLength(256); } } diff --git a/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index 7d35d9b..c18fd81 100644 --- a/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -13,7 +13,6 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator x.NewName is not null); RuleFor(x => x.NewDescription) - .NotEmpty() .MaximumLength(256) .When(x => x.NewDescription is not null); } diff --git a/backend/Musify.Application/Musify.Application.csproj b/backend/Musify.Application/Musify.Application.csproj index e6266ce..ce975d0 100644 --- a/backend/Musify.Application/Musify.Application.csproj +++ b/backend/Musify.Application/Musify.Application.csproj @@ -13,6 +13,7 @@ + diff --git a/backend/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Musify.Application/PlayLists/GetPlayListCover.cs new file mode 100644 index 0000000..ea3d87b --- /dev/null +++ b/backend/Musify.Application/PlayLists/GetPlayListCover.cs @@ -0,0 +1,49 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using MimeMapping; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists +{ + public record GetPlayListCoverQuery(Guid PlayListId, string Size) + : IQuery>; + + public record PlayListCoverLocation(string Bucket, string Key, string ContentType); + + public class GetPlayListCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration) + : IQueryHandler> + { + public async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists.AsNoTracking() + .Where(p => p.Id == request.PlayListId) + .Select(p => new { p.SmallPictureName, p.MediumPictureName, p.LargePictureName }) + .SingleOrDefaultAsync(cancellationToken); + + if (playList == null) + return Error.NotFound(); + + var routes = playListConfiguration.Routes; + var (name, key, presetName) = request.Size.ToLowerInvariant() switch + { + "small" => (playList.SmallPictureName, BuildKey(routes.BuildSmallPicturePath, playList.SmallPictureName), routes.PresetSmallPicture), + "large" => (playList.LargePictureName, BuildKey(routes.BuildLargePicturePath, playList.LargePictureName), routes.PresetLargePicture), + _ => (playList.MediumPictureName, BuildKey(routes.BuildMediumPicturePath, playList.MediumPictureName), routes.PresetMediumPicture) + }; + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key) || name == presetName) + return Error.NotFound(); + + var contentType = MimeUtility.GetMimeMapping(name); + return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); + } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); + } +}