From 0b72363c331e4f20caa4621bdd9a22606255a4f7 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 14 Jul 2026 04:54:40 +0200 Subject: [PATCH] feat(tracks): add track cover endpoint Serve cover images via GET /tracks/{id}/cover with a size parameter, streamed from storage with long-lived immutable cache headers. --- .../Musify.Api/Endpoints/TrackEndpoints.cs | 25 +++++++++ .../Tracks/GetTrackCover.cs | 53 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 backend/Musify.Application/Tracks/GetTrackCover.cs diff --git a/backend/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Musify.Api/Endpoints/TrackEndpoints.cs index cafa59e..8ebf318 100644 --- a/backend/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Musify.Api/Endpoints/TrackEndpoints.cs @@ -2,6 +2,7 @@ using Mediator; 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; @@ -27,6 +28,12 @@ public static class TrackEndpoints .Produces() .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.") @@ -94,6 +101,24 @@ public static class TrackEndpoints return result.ToHttpResult(); } + private static async Task 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 GetTrackStream( IMediator mediator, CurrentUser currentUser, diff --git a/backend/Musify.Application/Tracks/GetTrackCover.cs b/backend/Musify.Application/Tracks/GetTrackCover.cs new file mode 100644 index 0000000..903f8a6 --- /dev/null +++ b/backend/Musify.Application/Tracks/GetTrackCover.cs @@ -0,0 +1,53 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks +{ + public record GetTrackCoverQuery(Guid TrackId, string Size) + : IQuery>; + + public record TrackCoverLocation(string Bucket, string Key, string ContentType); + + public class GetTrackCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration) + : IQueryHandler> + { + public async ValueTask> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId) + .Select(t => new { t.SmallPictureName, t.MediumPictureName, t.LargePictureName }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return Error.NotFound(); + + var routes = trackConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch + { + "small" => (track.SmallPictureName, BuildKey(routes.BuildSmallPicturePath, track.SmallPictureName)), + "large" => (track.LargePictureName, BuildKey(routes.BuildLargePicturePath, track.LargePictureName)), + _ => (track.MediumPictureName, BuildKey(routes.BuildMediumPicturePath, track.MediumPictureName)) + }; + + if (string.IsNullOrEmpty(name) || key == null) + return Error.NotFound(); + + var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) + ? "image/webp" + : name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) + ? "image/png" + : "image/jpeg"; + + return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); + } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name)? null: builder(name); + } +} \ No newline at end of file