From d839a727008bf85d20ce35014006488120424245 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 25 Sep 2026 01:14:59 +0200 Subject: [PATCH 1/2] Add track status filter and counts, album track ids and bulk track deletion GET /tracks/users/{userId} accepts repeated status values and returns the albums each track belongs to. GET /tracks/users/{userId}/status-counts, GET /albums/{id}/tracks/ids and DELETE /tracks with a list of track ids are new. --- .../Albums/GetAlbumTrackIds.cs | 35 +++ .../Albums/Responses/AlbumTrackIdsResponse.cs | 4 + .../Musify.Application/Tracks/DeleteTracks.cs | 60 +++++ .../Tracks/GetTrackStatusCounts.cs | 35 +++ .../Tracks/GetTracksByUserId.cs | 18 +- .../Tracks/Responses/DeleteTracksResponse.cs | 6 + .../Tracks/Responses/TrackProjections.cs | 20 ++ .../Responses/TrackStatusCountsResponse.cs | 9 + .../Responses/UserTrackApplicationResponse.cs | 20 ++ .../Tracks/DeleteTracksRequest.cs | 4 + .../Musify.Api/Endpoints/AlbumEndpoints.cs | 19 ++ .../Musify.Api/Endpoints/TrackEndpoints.cs | 52 ++++- .../Tracks/DeleteTracksRequestValidator.cs | 21 ++ .../Endpoints/TrackLibraryEndpointsTests.cs | 220 ++++++++++++++++++ .../GetAlbumTrackIdsQueryHandlerTests.cs | 53 +++++ .../Tracks/DeleteTracksCommandHandlerTests.cs | 81 +++++++ .../GetTrackStatusCountsQueryHandlerTests.cs | 45 ++++ .../GetTracksByUserIdStatusAndAlbumsTests.cs | 79 +++++++ 18 files changed, 773 insertions(+), 8 deletions(-) create mode 100644 backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs create mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/DeleteTracks.cs create mode 100644 backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs b/backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs new file mode 100644 index 0000000..3d64ea0 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs @@ -0,0 +1,35 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums +{ + public record GetAlbumTrackIdsQuery(long UserId, Guid AlbumId) + : IQuery>; + + public class GetAlbumTrackIdsQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumTrackIdsQuery request, CancellationToken cancellationToken) + { + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + + var trackIds = await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId + && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ThenBy(albumTrack => albumTrack.Id) + .Select(albumTrack => albumTrack.TrackId) + .ToListAsync(cancellationToken); + + return new AlbumTrackIdsResponse(trackIds); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs new file mode 100644 index 0000000..43c169c --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Albums.Responses +{ + public record AlbumTrackIdsResponse(IReadOnlyList TrackIds); +} diff --git a/backend/Core/Musify.Application/Tracks/DeleteTracks.cs b/backend/Core/Musify.Application/Tracks/DeleteTracks.cs new file mode 100644 index 0000000..46e4b23 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/DeleteTracks.cs @@ -0,0 +1,60 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks +{ + public record DeleteTracksCommand(long UserId, IReadOnlyList TrackIds) + : ICommand>; + + public class DeleteTracksCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(DeleteTracksCommand request, CancellationToken cancellationToken) + { + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .ToListAsync(cancellationToken); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + + var deleted = new List(); + var failed = new List(); + + foreach (var trackId in requestedIds) + { + var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId); + if (track == null || track.Audio.IsInProgress) + { + failed.Add(trackId); + continue; + } + + track.LifeCycleStatus = LifeCycleStatus.Removing; + await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); + deleted.Add(trackId); + } + + if (deleted.Count > 0) + { + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Count} tracks of user {UserId} as removing", deleted.Count, request.UserId); + } + + return new DeleteTracksResponse(deleted, failed); + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs b/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs new file mode 100644 index 0000000..217893e --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs @@ -0,0 +1,35 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks +{ + public record GetTrackStatusCountsQuery(long UserId) + : IQuery; + + public class GetTrackStatusCountsQueryHandler(IDatabase database) + : IQueryHandler + { + public async ValueTask Handle(GetTrackStatusCountsQuery request, CancellationToken cancellationToken) + { + var counts = await database.Tracks + .AsNoTracking() + .Active() + .Where(track => track.OwnerUserId == request.UserId) + .GroupBy(track => track.Audio.TranscodeStatus) + .Select(group => new { Status = group.Key, Count = group.Count() }) + .ToListAsync(cancellationToken); + + int CountOf(ProcessingStatus status) => counts.Where(c => c.Status == status).Sum(c => c.Count); + + return new TrackStatusCountsResponse( + counts.Sum(c => c.Count), + CountOf(ProcessingStatus.Pending), + CountOf(ProcessingStatus.Processing), + CountOf(ProcessingStatus.Completed), + CountOf(ProcessingStatus.Failed)); + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 43b799d..78f29a0 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { @@ -11,13 +12,14 @@ namespace Musify.Application.Tracks long UserId, string? Name, int PageNumber, - int PageSize) - : IQuery>>; + int PageSize, + IReadOnlyList? Statuses = null) + : IQuery>>; public class GetTracksByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler>> { - public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) + public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { var tracksQuery = database.Tracks .AsNoTracking() @@ -30,10 +32,16 @@ namespace Musify.Application.Tracks tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); } + if (request.Statuses is { Count: > 0 } statuses) + { + var statusList = statuses.Distinct().ToList(); + tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus)); + } + return await tracksQuery .OrderByDescending(track => track.CreatedAt) .ThenBy(track => track.Id) - .SelectResponse() + .SelectUserResponse() .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs new file mode 100644 index 0000000..caf45c7 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Tracks.Responses +{ + public record DeleteTracksResponse( + IReadOnlyList DeletedTrackIds, + IReadOnlyList FailedTrackIds); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs index df228d3..e4dc924 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -21,5 +21,25 @@ namespace Musify.Application.Tracks.Responses track.OwnerUserId, track.Tags.Select(tag => tag.Tag).ToList(), track.IsExplicit)); + + public static IQueryable SelectUserResponse(this IQueryable query) => + query.Select(track => new UserTrackApplicationResponse( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + track.ListeningHistories.Count(listen => listen.IsCounted), + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit, + track.AlbumTracks + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(albumTrack => albumTrack.AddedAt) + .ThenBy(albumTrack => albumTrack.AlbumId) + .Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title)) + .ToList())); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs new file mode 100644 index 0000000..97a1f12 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Tracks.Responses +{ + public record TrackStatusCountsResponse( + int Total, + int Pending, + int Processing, + int Completed, + int Failed); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs new file mode 100644 index 0000000..2fde813 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs @@ -0,0 +1,20 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses +{ + public record TrackAlbumReference(Guid Id, string Title); + + public record UserTrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + long OwnerUserId, + IReadOnlyCollection Tags, + bool IsExplicit, + IReadOnlyList Albums); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs new file mode 100644 index 0000000..36d6dc2 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record DeleteTracksRequest(IReadOnlyList TrackIds); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 275f238..c56d647 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -57,6 +57,15 @@ namespace Musify.Api.Endpoints .Produces>() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/tracks/ids", GetAlbumTrackIds) + .WithName("GetAlbumTrackIds") + .WithSummary("Get the ids of every track in one of your albums, ordered by track number") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetAlbumCover) .WithName("GetAlbumCover") .WithSummary("Get an album cover image") @@ -195,6 +204,16 @@ namespace Musify.Api.Endpoints return result.ToHttpResult(); } + private static async Task GetAlbumTrackIds( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumTrackIdsQuery(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + private static Task GetAlbumCover( IMediator mediator, IStorageService storageService, diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 7a15b54..4d2561a 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -1,4 +1,5 @@ using Mediator; +using Microsoft.AspNetCore.Mvc; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; @@ -9,6 +10,7 @@ using Musify.Application.Pictures; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints { @@ -57,8 +59,16 @@ namespace Musify.Api.Endpoints group.MapGet("/users/{userId:long}", GetTracksByUserId) .AddEndpointFilter>() .WithName("GetTracksByUserId") - .WithSummary("Get paginated tracks for a user") - .Produces>(); + .WithSummary("Get paginated tracks for a user, optionally filtered by name and audio processing status. Each track lists the albums it belongs to") + .Produces>(); + + group.MapGet("/users/{userId:long}/status-counts", GetTrackStatusCounts) + .WithName("GetTrackStatusCounts") + .WithSummary("Get how many of your tracks are in each audio processing status") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); group.MapPost("/", CreateTrack) .WithName("CreateTrack") @@ -81,6 +91,16 @@ namespace Musify.Api.Endpoints .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); + group.MapDelete("/", DeleteTracks) + .WithName("DeleteTracks") + .WithSummary("Delete several of your tracks at once. Tracks that are missing or still processing are returned as failed") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + group.MapPost("/upload-urls", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") .WithSummary("Request pre-signed URLs to upload track picture and audio") @@ -154,9 +174,35 @@ namespace Musify.Api.Endpoints long userId, string? name, [AsParameters] PageQuery page, + CancellationToken cancellationToken, + ProcessingStatus[]? status = null) + { + var result = await mediator.Send( + new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status), + cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetTrackStatusCounts( + IMediator mediator, + CurrentUser currentUser, + long userId, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); + if (currentUser.RequiredId != userId) + return Results.Problem("You can only read the status counts of your own tracks.", statusCode: StatusCodes.Status403Forbidden); + + var result = await mediator.Send(new GetTrackStatusCountsQuery(userId), cancellationToken); + return Results.Ok(result); + } + + private static async Task DeleteTracks( + IMediator mediator, + CurrentUser currentUser, + [FromBody] DeleteTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteTracksCommand(currentUser.RequiredId, request.TrackIds), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs new file mode 100644 index 0000000..436c43f --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; + +namespace Musify.Api.Validators.Tracks +{ + public sealed class DeleteTracksRequestValidator : AbstractValidator + { + public const int MaxTracksPerRequest = 100; + + public DeleteTracksRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be deleted at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs new file mode 100644 index 0000000..3447fc5 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs @@ -0,0 +1,220 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class TrackLibraryEndpointsTests(ApiTestFixture fixture) + { + private async Task SeedTrackAsync( + long ownerId, + string title, + ProcessingStatus status = ProcessingStatus.Completed) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = ownerId, + Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null } + }; + database.Tracks.Add(track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return track.Id; + } + + private async Task SeedAlbumWithTracksAsync(long ownerId, params Guid[] trackIds) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var album = new Album { Title = "Album", NormalizedTitle = "ALBUM", OwnerUserId = ownerId }; + database.Albums.Add(album); + var number = 1; + foreach (var trackId in trackIds) + database.AlbumHasTracks.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = trackId, TrackNumber = number++ }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return album.Id; + } + + private static int Number(JsonElement element) => + element.ValueKind == JsonValueKind.String + ? int.Parse(element.GetString()!, CultureInfo.InvariantCulture) + : element.GetInt32(); + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + return JsonDocument.Parse(text).RootElement.Clone(); + } + + [Fact] + public async Task GetUserTracks_StatusFilterBindsRepeatedQueryValues() + { + var userId = await fixture.SeedUserAsync(); + await SeedTrackAsync(userId, "Done"); + await SeedTrackAsync(userId, "Pending", ProcessingStatus.Pending); + await SeedTrackAsync(userId, "Failed", ProcessingStatus.Failed); + + var response = await fixture.CreateAnonymousClient().GetAsync( + $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}?status=Pending&status=Failed", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + var titles = body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("title").GetString()).Order().ToList(); + Assert.Equal(["Failed", "Pending"], titles); + Assert.Equal(2, Number(body.GetProperty("totalItemCount"))); + } + + [Fact] + public async Task GetUserTracks_ReturnsAlbumsOfEachTrack() + { + var userId = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(userId, "In album"); + var albumId = await SeedAlbumWithTracksAsync(userId, trackId); + + var response = await fixture.CreateAnonymousClient().GetAsync( + $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}", TestContext.Current.CancellationToken); + + var body = await ReadJsonAsync(response); + var albums = body.GetProperty("items")[0].GetProperty("albums"); + Assert.Equal(1, albums.GetArrayLength()); + Assert.Equal(albumId, albums[0].GetProperty("id").GetGuid()); + Assert.Equal("Album", albums[0].GetProperty("title").GetString()); + } + + [Fact] + public async Task GetStatusCounts_Owner_ReturnsCountsPerStatus() + { + var userId = await fixture.SeedUserAsync(); + await SeedTrackAsync(userId, "A"); + await SeedTrackAsync(userId, "B", ProcessingStatus.Processing); + await SeedTrackAsync(userId, "C", ProcessingStatus.Failed); + + var response = await fixture.CreateAuthenticatedClient(userId).GetAsync( + $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}/status-counts", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + Assert.Equal(3, Number(body.GetProperty("total"))); + Assert.Equal(1, Number(body.GetProperty("completed"))); + Assert.Equal(1, Number(body.GetProperty("processing"))); + Assert.Equal(1, Number(body.GetProperty("failed"))); + Assert.Equal(0, Number(body.GetProperty("pending"))); + } + + [Fact] + public async Task GetStatusCounts_AnotherUser_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + + var response = await fixture.CreateAuthenticatedClient(stranger).GetAsync( + $"/tracks/users/{owner.ToString(CultureInfo.InvariantCulture)}/status-counts", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetStatusCounts_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks/users/1/status-counts", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetAlbumTrackIds_Owner_ReturnsIdsInOrder() + { + var userId = await fixture.SeedUserAsync(); + var first = await SeedTrackAsync(userId, "First"); + var second = await SeedTrackAsync(userId, "Second"); + var albumId = await SeedAlbumWithTracksAsync(userId, second, first); + + var response = await fixture.CreateAuthenticatedClient(userId).GetAsync($"/albums/{albumId}/tracks/ids", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + var ids = body.GetProperty("trackIds").EnumerateArray().Select(e => e.GetGuid()).ToList(); + Assert.Equal([second, first], ids); + } + + [Fact] + public async Task GetAlbumTrackIds_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var albumId = await SeedAlbumWithTracksAsync(owner); + + var response = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/albums/{albumId}/tracks/ids", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DeleteTracks_Anonymous_ReturnsUnauthorized() + { + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([Guid.NewGuid()])) }; + + var response = await fixture.CreateAnonymousClient().SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task DeleteTracks_EmptyList_ReturnsBadRequest() + { + var userId = await fixture.SeedUserAsync(); + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([])) }; + + var response = await fixture.CreateAuthenticatedClient(userId).SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DeleteTracks_OwnTracks_MarksThemRemovingAndReportsFailures() + { + var userId = await fixture.SeedUserAsync(); + var done = await SeedTrackAsync(userId, "Done"); + var busy = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending); + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([done, busy])) }; + + var response = await fixture.CreateAuthenticatedClient(userId).SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + Assert.Equal([done], body.GetProperty("deletedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList()); + Assert.Equal([busy], body.GetProperty("failedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList()); + + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var stored = await database.Tracks.AsNoTracking().SingleAsync(t => t.Id == done, TestContext.Current.CancellationToken); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + } + + [Fact] + public async Task DeleteTracks_TrackOfAnotherUser_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(owner, "Theirs"); + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([trackId])) }; + + var response = await fixture.CreateAuthenticatedClient(stranger).SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs new file mode 100644 index 0000000..6a05ad9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs @@ -0,0 +1,53 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetAlbumTrackIdsQueryHandlerTests : HandlerTestBase + { + private GetAlbumTrackIdsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Owner_ReturnsAllActiveTrackIdsInOrderBeyondAPage() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var tracks = Enumerable.Range(1, 120).Select(i => TestEntities.Track(owner, $"T{i}")).ToList(); + var removing = TestEntities.Track(owner, "Removing", lifeCycleStatus: LifeCycleStatus.Removing); + await SeedAsync([owner, album, removing, .. tracks]); + var links = tracks.Select((t, i) => new AlbumHasTrack { AlbumId = album.Id, TrackId = t.Id, TrackNumber = i + 1 }).ToList(); + links.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = removing.Id, TrackNumber = 999 }); + await SeedAsync([.. links]); + + var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(owner.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(tracks.Select(t => t.Id), result.Value.TrackIds); + } + + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); + + var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(stranger.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_MissingAlbum_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs new file mode 100644 index 0000000..3fa8a6d --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs @@ -0,0 +1,81 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class DeleteTracksCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + + private DeleteTracksCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [Fact] + public async Task Handle_OwnTracks_MarksAllAsRemovingAndPublishesOneEventEach() + { + var owner = TestEntities.User(); + var first = TestEntities.Track(owner, "One"); + var second = TestEntities.Track(owner, "Two"); + await SeedAsync(owner, first, second); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [first.Id, second.Id]), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.DeletedTrackIds.Count); + Assert.Empty(result.Value.FailedTrackIds); + Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Removing, track.LifeCycleStatus)); + await eventBus.Received(2).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_TrackOfAnotherUser_ReturnsForbiddenAndDeletesNothing() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var mine = TestEntities.Track(owner, "Mine"); + var theirs = TestEntities.Track(stranger, "Theirs"); + await SeedAsync(owner, stranger, mine, theirs); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [mine.Id, theirs.Id]), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Active, track.LifeCycleStatus)); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ProcessingAndMissingTracks_AreReturnedAsFailed() + { + var owner = TestEntities.User(); + var done = TestEntities.Track(owner, "Done"); + var busy = TestEntities.Track(owner, "Busy", audio: TestEntities.PendingAudio()); + var missing = Guid.NewGuid(); + await SeedAsync(owner, done, busy); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [done.Id, busy.Id, missing]), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal([done.Id], result.Value.DeletedTrackIds); + Assert.Equal([busy.Id, missing], result.Value.FailedTrackIds); + Assert.Equal(LifeCycleStatus.Active, Database.Tracks.Single(t => t.Id == busy.Id).LifeCycleStatus); + } + + [Fact] + public async Task Handle_DuplicateIds_AreDeletedOnce() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [track.Id, track.Id]), TestContext.Current.CancellationToken); + + Assert.Single(result.Value.DeletedTrackIds); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs new file mode 100644 index 0000000..044988a --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs @@ -0,0 +1,45 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTrackStatusCountsQueryHandlerTests : HandlerTestBase + { + [Fact] + public async Task Handle_CountsOnlyActiveTracksOfTheUser() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + await SeedAsync( + owner, other, + TestEntities.Track(owner, "Done 1"), + TestEntities.Track(owner, "Done 2"), + TestEntities.Track(owner, "Pending", audio: TestEntities.PendingAudio()), + TestEntities.Track(owner, "Processing", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Processing }), + TestEntities.Track(owner, "Failed", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Failed }), + TestEntities.Track(owner, "Removing", lifeCycleStatus: LifeCycleStatus.Removing), + TestEntities.Track(other, "Theirs")); + + var counts = await new GetTrackStatusCountsQueryHandler(Database) + .Handle(new GetTrackStatusCountsQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(5, counts.Total); + Assert.Equal(1, counts.Pending); + Assert.Equal(1, counts.Processing); + Assert.Equal(2, counts.Completed); + Assert.Equal(1, counts.Failed); + } + + [Fact] + public async Task Handle_NoTracks_ReturnsZeros() + { + var counts = await new GetTrackStatusCountsQueryHandler(Database) + .Handle(new GetTrackStatusCountsQuery(99), TestContext.Current.CancellationToken); + + Assert.Equal(0, counts.Total); + Assert.Equal(0, counts.Completed); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs new file mode 100644 index 0000000..8803b1b --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs @@ -0,0 +1,79 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTracksByUserIdStatusAndAlbumsTests : HandlerTestBase + { + private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_StatusFilter_ReturnsOnlyMatchingStatuses() + { + var owner = TestEntities.User(); + var done = TestEntities.Track(owner, "Done"); + var pending = TestEntities.Track(owner, "Pending", audio: TestEntities.PendingAudio()); + var failed = TestEntities.Track(owner, "Failed", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Failed }); + await SeedAsync(owner, done, pending, failed); + + var result = await CreateHandler().Handle( + new GetTracksByUserIdQuery(owner.Id, null, 1, 10, [ProcessingStatus.Pending, ProcessingStatus.Failed]), + TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["Failed", "Pending"], result.Value.Items.Select(t => t.Title).Order().ToList()); + Assert.Equal(2, result.Value.TotalItemCount); + } + + [Fact] + public async Task Handle_NoStatusFilter_ReturnsEveryStatus() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Track(owner, "A"), TestEntities.Track(owner, "B", audio: TestEntities.PendingAudio())); + + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Value.TotalItemCount); + } + + [Fact] + public async Task Handle_ListsActiveAlbumsOfEachTrack() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, "Song"); + var loose = TestEntities.Track(owner, "Loose"); + var first = TestEntities.Album(owner.Id, "First"); + var second = TestEntities.Album(owner.Id, "Second"); + var removed = TestEntities.Album(owner.Id, "Gone"); + removed.LifeCycleStatus = LifeCycleStatus.Removing; + await SeedAsync( + owner, track, loose, first, second, removed, + new AlbumHasTrack { AlbumId = first.Id, TrackId = track.Id }, + new AlbumHasTrack { AlbumId = second.Id, TrackId = track.Id }, + new AlbumHasTrack { AlbumId = removed.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), TestContext.Current.CancellationToken); + + var song = result.Value.Items.Single(t => t.Title == "Song"); + Assert.Equal(["First", "Second"], song.Albums.Select(a => a.Title).Order().ToList()); + Assert.Contains(song.Albums, a => a.Id == first.Id); + Assert.Empty(result.Value.Items.Single(t => t.Title == "Loose").Albums); + } + + [Fact] + public async Task Handle_Paginates() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Track(owner, "A"), TestEntities.Track(owner, "B"), TestEntities.Track(owner, "C")); + + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 2, 2), TestContext.Current.CancellationToken); + + Assert.Single(result.Value.Items); + Assert.Equal(3, result.Value.TotalItemCount); + Assert.True(result.Value.HasPreviousPage); + } + } +} From ebfd41b1606c294b840528b85a5d5ace080ee31a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 25 Sep 2026 01:37:55 +0200 Subject: [PATCH 2/2] Add track publication, album types, optional track pictures and batch upload URLs Tracks - Track.IsPublished (migration marks existing tracks as published). New tracks are created unpublished. - PUT /tracks/{id}/published and PUT /tracks/published change the flag; publishing requires completed audio processing (the batch endpoint reports skipped tracks). - An unpublished track is only visible to its owner. A WherePublished / VisibleTo filter is applied to search, genres, listening history, last listened, likes, mixes, album and playlist contents and counts, recently listened albums, public user tracks, track details and stream tickets. - isPublished is part of the track responses, GET /tracks/users/{userId} accepts a published filter and status-counts reports published and unpublished totals. - CreateTrackRequest.pictureIntentId is optional: without it the track uses the album cover or the preset picture and no picture processing runs. CreateTrackResourcesEvent carries nullable picture fields, so queues must be drained before deploying. - POST /tracks/upload-urls accepts an audios list and one optional picture shared by all of them, and returns an intent per file. The upload intent limits are raised so a batch fits. Albums - AlbumType (Album, EP, Single) on create, update and responses. - CreateAlbumRequest.trackIds creates the album and links the tracks in a single save. --- .../Musify.Application/Albums/CreateAlbum.cs | 37 +- .../Albums/GetAlbumTracks.cs | 2 +- .../Albums/GetRecentlyListenedAlbums.cs | 1 + .../Responses/AlbumApplicationResponse.cs | 7 +- .../Albums/Responses/AlbumProjections.cs | 6 +- .../Musify.Application/Albums/UpdateAlbum.cs | 5 +- .../UploadIntentConfiguration.cs | 4 +- .../Events/CreateTrackResourcesEvent.cs | 6 +- .../Musify.Application/Genres/GetGenres.cs | 2 +- .../Likes/GetLikedTracks.cs | 2 +- .../Likes/ToggleTrackLike.cs | 2 +- .../Mixes/GenerateMixesForUser.cs | 2 +- .../Mixes/GetMixesByUserId.cs | 10 +- .../Musify.Application/Mixes/MixItemMapper.cs | 18 +- .../Musify.Application/Pictures/GetCover.cs | 34 +- .../PlayLists/AddAlbumToPlayList.cs | 2 +- .../PlayLists/AddTracksToPlayList.cs | 2 +- .../PlayLists/GetPlayListTracks.cs | 2 +- .../Responses/PlayListProjections.cs | 5 +- .../Services/UploadIntentIssuer.cs | 7 +- .../Musify.Application/Tracks/CreateTrack.cs | 31 +- .../Tracks/GetLastTrackListenedByUserId.cs | 2 +- .../Tracks/GetListeningHistory.cs | 1 + .../Musify.Application/Tracks/GetTrackById.cs | 3 +- .../Tracks/GetTrackStatusCounts.cs | 15 +- .../Musify.Application/Tracks/GetTracks.cs | 3 +- .../Tracks/GetTracksByUserId.cs | 10 +- .../Tracks/RequestTrackUploadUrls.cs | 93 +- .../Responses/SetTracksPublishedResponse.cs | 6 + .../Responses/TrackApplicationResponse.cs | 6 +- .../Tracks/Responses/TrackProjections.cs | 10 +- .../Responses/TrackStatusCountsResponse.cs | 4 +- .../Responses/TrackUploadUrlsResponse.cs | 21 +- .../Responses/UserTrackApplicationResponse.cs | 1 + .../Tracks/SetTrackPublished.cs | 38 + .../Tracks/SetTracksPublished.cs | 58 + .../Tracks/StartListening.cs | 1 + backend/Core/Musify.Domain/Entities/Album.cs | 2 + backend/Core/Musify.Domain/Entities/Track.cs | 2 + .../Musify.Domain/ValueObjects/AlbumType.cs | 12 + .../PublishTrackProcessingEventsActivity.cs | 17 +- .../PublishTrackProcessingEventsArguments.cs | 2 +- .../Builders/CreateTrackRoutingSlipBuilder.cs | 24 +- .../Builders/DeleteRoutingSlipBuilder.cs | 2 +- .../Sagas/TrackProcessingStateMachine.cs | 1 + ...9_TrackPublicationAndAlbumType.Designer.cs | 1090 +++++++++++++++++ ...0924231849_TrackPublicationAndAlbumType.cs | 42 + .../Migrations/DatabaseModelSnapshot.cs | 6 + .../Albums/CreateAlbumRequest.cs | 6 +- .../Albums/UpdateAlbumRequest.cs | 5 +- .../Tracks/CreateTrackRequest.cs | 2 +- .../Tracks/RequestTrackUploadUrlsRequest.cs | 16 +- .../Tracks/SetTrackPublishedRequest.cs | 4 + .../Tracks/SetTracksPublishedRequest.cs | 4 + .../Musify.Api/Endpoints/AlbumEndpoints.cs | 8 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 61 +- .../Albums/CreateAlbumRequestValidator.cs | 14 + .../Albums/UpdateAlbumRequestValidator.cs | 4 + .../Tracks/CreateTrackRequestValidator.cs | 3 +- .../RequestTrackUploadUrlsRequestValidator.cs | 52 +- .../SetTracksPublishedRequestValidator.cs | 21 + backend/Hosts/Musify.Api/appsettings.json | 4 +- backend/Hosts/Musify.Worker/appsettings.json | 4 +- .../Endpoints/AlbumEndpointsTests.cs | 19 +- .../Endpoints/GenreEndpointsTests.cs | 1 + .../Endpoints/TrackLibraryEndpointsTests.cs | 4 +- .../TrackPublicationEndpointsTests.cs | 298 +++++ .../Endpoints/UserEndpointsTests.cs | 3 +- .../LibraryRequestValidatorTests.cs | 122 ++ .../CreateAlbumWithTracksAndTypeTests.cs | 142 +++ .../TestSupport/TestEntities.cs | 4 +- .../Tracks/DeleteTracksCommandHandlerTests.cs | 10 +- .../RequestTrackUploadUrlsBatchTests.cs | 119 ++ .../SetTrackPublishedCommandHandlerTests.cs | 146 +++ .../TrackCreationWithoutPictureTests.cs | 152 +++ .../Tracks/UnpublishedTrackVisibilityTests.cs | 259 ++++ .../DeleteRoutingSlipBuilderTests.cs | 26 + .../ProcessingStateMachineTests.cs | 18 + .../MassTransit/RoutingSlipBuilderTests.cs | 37 + 79 files changed, 3076 insertions(+), 151 deletions(-) create mode 100644 backend/Core/Musify.Application/Tracks/Responses/SetTracksPublishedResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/SetTrackPublished.cs create mode 100644 backend/Core/Musify.Application/Tracks/SetTracksPublished.cs create mode 100644 backend/Core/Musify.Domain/ValueObjects/AlbumType.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTrackPublishedRequest.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTracksPublishedRequest.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Tracks/SetTracksPublishedRequestValidator.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/TrackPublicationEndpointsTests.cs create mode 100644 backend/Tests/Musify.Api.Tests/Validators/LibraryRequestValidatorTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/CreateAlbumWithTracksAndTypeTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsBatchTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/SetTrackPublishedCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/TrackCreationWithoutPictureTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/UnpublishedTrackVisibilityTests.cs diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 8be75e6..68acdcd 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -19,7 +19,9 @@ namespace Musify.Application.Albums string Title, string? Description, int? ReleaseYear, - Guid PictureIntentId) + Guid PictureIntentId, + AlbumType Type = AlbumType.Album, + IReadOnlyList? TrackIds = null) : ICommand>; public class CreateAlbumCommandHandler( @@ -38,6 +40,21 @@ namespace Musify.Application.Albums if (!userExists) return AppErrors.NotFound("User", request.UserId); + var trackIds = (request.TrackIds ?? []).Distinct().ToList(); + var tracks = await database.Tracks + .AsNoTracking() + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { track.Id, track.OwnerUserId, track.IsPublished }) + .ToListAsync(cancellationToken); + + var missing = trackIds.FirstOrDefault(id => tracks.All(track => track.Id != id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + var prepared = await PictureSourceChange.PrepareAsync( uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); if (prepared.IsError) @@ -53,11 +70,19 @@ namespace Musify.Application.Albums NormalizedTitle = TextNormalizer.Normalize(title), Description = request.Description, ReleaseYear = request.ReleaseYear, + Type = request.Type, Pictures = picture.Pictures }; await database.Albums.AddAsync(album, cancellationToken); + for (var index = 0; index < trackIds.Count; index++) + { + await database.AlbumHasTracks.AddAsync( + new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 }, + cancellationToken); + } + await eventBus.PublishAsync( new CreateAlbumResourcesEvent( album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), @@ -65,9 +90,15 @@ namespace Musify.Application.Albums await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count); - return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + var published = tracks.Where(track => track.IsPublished).Select(track => track.Id).ToHashSet(); + var visibleTrackIds = trackIds.Where(published.Contains).ToList(); + + return AlbumApplicationResponse.FromEntity( + album, + visibleTrackIds.Count, + [.. visibleTrackIds.Take(AlbumProjections.CoverTrackCount)]); } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index 7da6847..ac05231 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -28,7 +28,7 @@ namespace Musify.Application.Albums return await database.AlbumHasTracks .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished) .OrderBy(albumTrack => albumTrack.TrackNumber) .ThenBy(albumTrack => albumTrack.Id) .Select(albumTrack => albumTrack.Track) diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index 2f87e1a..29515e3 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -20,6 +20,7 @@ namespace Musify.Application.Albums var ranked = await database.AlbumHasTracks .AsNoTracking() .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) + .Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished) .Join( listens, albumTrack => albumTrack.TrackId, diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index 77ce78e..5bf8afd 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,4 +1,5 @@ using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums.Responses { @@ -14,7 +15,8 @@ namespace Musify.Application.Albums.Responses string? LargeImageKeyName, DateTime CreatedAt, DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) + IReadOnlyList CoverTrackIds, + AlbumType Type) { public const int CoverTrackCount = AlbumProjections.CoverTrackCount; @@ -35,7 +37,8 @@ namespace Musify.Application.Albums.Responses album.Pictures?.LargeName, album.CreatedAt, album.UpdatedAt, - coverTrackIds ?? []); + coverTrackIds ?? [], + album.Type); } } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs index cf4b63b..c1f6181 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs @@ -17,16 +17,18 @@ namespace Musify.Application.Albums.Responses album.Description, album.ReleaseYear, album.OwnerUserId, - album.AlbumTracks.Count, + album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished), album.Pictures!.SmallName, album.Pictures!.MediumName, album.Pictures!.LargeName, album.CreatedAt, album.UpdatedAt, album.AlbumTracks + .Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished) .OrderBy(albumTrack => albumTrack.TrackNumber) .Take(CoverTrackCount) .Select(albumTrack => albumTrack.TrackId) - .ToList())); + .ToList(), + album.Type)); } } diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 7fe2e7c..0761f3c 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums { @@ -13,7 +14,8 @@ namespace Musify.Application.Albums Guid AlbumId, string Title, string? Description, - int? ReleaseYear) + int? ReleaseYear, + AlbumType? Type = null) : ICommand>; public class UpdateAlbumCommandHandler( @@ -34,6 +36,7 @@ namespace Musify.Application.Albums album.NormalizedTitle = TextNormalizer.Normalize(title); album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(); album.ReleaseYear = request.ReleaseYear; + album.Type = request.Type ?? album.Type; await database.SaveChangesAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs index eb68c90..5a2d6ed 100644 --- a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs @@ -10,10 +10,10 @@ namespace Musify.Application.Configuration public int UploadUrlExpiresInSeconds { get; set; } = 120; [Range(1, 100)] - public int MaxActiveUploadIntentsPerUser { get; set; } = 5; + public int MaxActiveUploadIntentsPerUser { get; set; } = 50; [Range(1, long.MaxValue)] - public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; + public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024; [Range(1, long.MaxValue)] public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; diff --git a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs index 6a78fd5..96d01ad 100644 --- a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs @@ -4,11 +4,11 @@ namespace Musify.Application.Events { public record CreateTrackResourcesEvent( Guid TrackId, - Guid PictureIntentId, + Guid? PictureIntentId, Guid AudioIntentId, string Bucket, - string PictureSourceKey, - string PictureDestinationKey, + string? PictureSourceKey, + string? PictureDestinationKey, string AudioSourceKey, string AudioDestinationKey, string AudioProcessedFolderKey, diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 680793e..99740c8 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -15,7 +15,7 @@ namespace Musify.Application.Genres { var rows = await database.TrackTags .AsNoTracking() - .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished) .GroupBy(tag => tag.Tag) .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index 5b4a2b1..b0f4639 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -20,7 +20,7 @@ namespace Musify.Application.Likes { return await database.TrackLikes .AsNoTracking() - .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished) .OrderByDescending(like => like.CreatedAt) .ThenBy(like => like.Id) .Select(like => like.Track) diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index c519ac8..7db23df 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -21,7 +21,7 @@ namespace Musify.Application.Likes { var trackExists = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken); if (!trackExists) return AppErrors.NotFound("Track", request.TrackId); diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 7cda638..e8574ed 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -25,7 +25,7 @@ namespace Musify.Application.Mixes var libraryTrackIds = await database.Tracks .AsNoTracking() - .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished) .Select(track => track.Id) .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index 73ea1bb..8418366 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -25,7 +25,13 @@ namespace Musify.Application.Mixes mix => mix.Id, mix => mix.Items.OrderBy(item => item.Position).ToList()); - var coverItems = orderedItemsByMix.Values + var visibleTrackIds = await MixItemMapper.VisibleTrackIdsAsync( + database, orderedItemsByMix.Values.SelectMany(items => items), cancellationToken); + var visibleItemsByMix = orderedItemsByMix.ToDictionary( + entry => entry.Key, + entry => entry.Value.Where(item => visibleTrackIds.Contains(item.TrackId)).ToList()); + + var coverItems = visibleItemsByMix.Values .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) .ToList(); var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) @@ -35,7 +41,7 @@ namespace Musify.Application.Mixes var responses = new List(mixes.Count); foreach (var mix in mixes) { - var orderedItems = orderedItemsByMix[mix.Id]; + var orderedItems = visibleItemsByMix[mix.Id]; var covers = orderedItems .Take(MixApplicationResponse.CoverItemCount) .Where(item => coverResponses.ContainsKey(item.TrackId)) diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index e5a6520..3478444 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -9,6 +9,22 @@ namespace Musify.Application.Mixes internal static class MixItemMapper { + public static async Task> VisibleTrackIdsAsync( + IDatabase database, + IEnumerable items, + CancellationToken cancellationToken) + { + var trackIds = items.Select(item => item.TrackId).Distinct().ToList(); + + var visible = await database.Tracks + .AsNoTracking() + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + return [.. visible]; + } + public static async Task> ToResponsesAsync( IDatabase database, IReadOnlyList orderedItems, @@ -19,7 +35,7 @@ namespace Musify.Application.Mixes var tracksById = await database.Tracks .AsNoTracking() .Include(track => track.Owner) - .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(x => x.Track.Id, cancellationToken); diff --git a/backend/Core/Musify.Application/Pictures/GetCover.cs b/backend/Core/Musify.Application/Pictures/GetCover.cs index 7d04ee5..6d87986 100644 --- a/backend/Core/Musify.Application/Pictures/GetCover.cs +++ b/backend/Core/Musify.Application/Pictures/GetCover.cs @@ -35,10 +35,7 @@ namespace Musify.Application.Pictures { var (source, routes) = request.Owner switch { - CoverOwner.Track => (await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName)) - .SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes), + CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken), CoverOwner.Album => (await database.Albums.AsNoTracking() .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) @@ -71,5 +68,34 @@ namespace Musify.Application.Pictures return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); } + + private async Task<(CoverSource? Source, PictureRoutes Routes)> FindTrackSourceAsync(Guid trackId, CancellationToken cancellationToken) + { + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == trackId && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new + { + Own = new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName), + Album = t.AlbumTracks + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Album.Pictures != null) + .OrderBy(albumTrack => albumTrack.AddedAt) + .ThenBy(albumTrack => albumTrack.AlbumId) + .Select(albumTrack => new CoverSource( + albumTrack.Album.OwnerUserId, + albumTrack.Album.Pictures!.OriginalName, + albumTrack.Album.Pictures.SmallName, + albumTrack.Album.Pictures.MediumName, + albumTrack.Album.Pictures.LargeName)) + .FirstOrDefault() + }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return (null, trackConfiguration.Routes); + + return string.IsNullOrEmpty(track.Own.Original) && track.Album != null + ? (track.Album, albumConfiguration.Routes) + : (track.Own, trackConfiguration.Routes); + } } } diff --git a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs index cf47936..6735353 100644 --- a/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddAlbumToPlayList.cs @@ -34,7 +34,7 @@ namespace Musify.Application.PlayLists var albumTrackIds = await database.AlbumHasTracks .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished) .OrderBy(albumTrack => albumTrack.TrackNumber) .Select(albumTrack => albumTrack.TrackId) .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs index fcc430b..a3bc5c3 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs @@ -27,7 +27,7 @@ namespace Musify.Application.PlayLists var existingIds = await database.Tracks .AsNoTracking() - .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished) .Select(track => track.Id) .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 9da4317..163b98a 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists return await database.PlayListHasTracks .AsNoTracking() - .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished) .OrderBy(playListTrack => playListTrack.Position) .ThenBy(playListTrack => playListTrack.Id) .Select(playListTrack => playListTrack.Track) diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs index 5f99617..a11776e 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs @@ -25,14 +25,15 @@ namespace Musify.Application.PlayLists.Responses playList.CreatedAt, playList.UpdatedAt, playList.PlayListTracks + .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished) .OrderBy(playListTrack => playListTrack.Position) .Take(CoverTrackCount) .Select(playListTrack => playListTrack.TrackId) .ToList(), playList.OwnerUserId, - playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), + playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished), playList.PlayListTracks - .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished) .Sum(playListTrack => playListTrack.Track.DurationSeconds))); } } diff --git a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs index 840a5c6..a7967b0 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -15,7 +15,8 @@ namespace Musify.Application.Services PictureRoutes Routes, string FileType, string ContentType, - long? ExpectedSizeBytes); + long? ExpectedSizeBytes, + int? SharedKeyGroup = null); public sealed record IssuedUpload( Guid IntentId, @@ -43,11 +44,15 @@ namespace Musify.Application.Services if (!userExists) return AppErrors.NotFound("User", userId); + var sharedKeys = new Dictionary(); var planned = requests .Select(request => { var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); + if (request.SharedKeyGroup is { } group) + key = sharedKeys.TryAdd(group, key) ? key : sharedKeys[group]; + var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); return (Request: request, ObjectName: objectName, Key: key, Size: size); }) diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 21fc7d4..b4b9569 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Tracks public record CreateTrackCommand( long UserId, string Title, - Guid PictureIntentId, + Guid? PictureIntentId, Guid AudioIntentId, IReadOnlyCollection Tags, bool IsExplicit = false) @@ -45,20 +45,27 @@ namespace Musify.Application.Tracks if (request.PictureIntentId == request.AudioIntentId) return Error.Validation(description: "Picture and audio must use different upload intents."); - var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( - request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); - if (pictureValidation.IsError) - return pictureValidation.Errors; + UploadIntent? pictureIntent = null; + if (request.PictureIntentId is { } pictureIntentId) + { + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( + pictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); + if (pictureValidation.IsError) + return pictureValidation.Errors; + + pictureIntent = pictureValidation.Value; + } var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); if (audioValidation.IsError) return audioValidation.Errors; - var pictureIntent = pictureValidation.Value; var audioIntent = audioValidation.Value; - var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + var finalPictureKey = pictureIntent == null + ? null + : trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); @@ -71,11 +78,11 @@ namespace Musify.Application.Tracks IsExplicit = request.IsExplicit, Pictures = new TrackPictures { - OriginalName = pictureIntent.ObjectName, + OriginalName = pictureIntent?.ObjectName, SmallName = trackConfiguration.Routes.PresetSmallPicture, MediumName = trackConfiguration.Routes.PresetMediumPicture, LargeName = trackConfiguration.Routes.PresetLargePicture, - ProcessingStatus = ProcessingStatus.Pending + ProcessingStatus = pictureIntent == null ? ProcessingStatus.Completed : ProcessingStatus.Pending }, Audio = new TrackAudio { @@ -94,10 +101,10 @@ namespace Musify.Application.Tracks await eventBus.PublishAsync( new CreateTrackResourcesEvent( trackEntity.Id, - pictureIntent.Id, + pictureIntent?.Id, audioIntent.Id, - pictureIntent.Bucket, - pictureIntent.Key, + audioIntent.Bucket, + pictureIntent?.Key, finalPictureKey, audioIntent.Key, finalAudioKey, diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 55e34af..76f5367 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -17,7 +17,7 @@ namespace Musify.Application.Tracks .AsNoTracking() .Include(t => t.Track.Owner) .Include(t => t.Track.Tags) - .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished) .OrderByDescending(t => t.ListenedAt) .ThenBy(t => t.Id) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 48a797a..8f29c1e 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -27,6 +27,7 @@ namespace Musify.Application.Tracks var tracksById = await database.Tracks .AsNoTracking() .Active() + .WherePublished() .Where(t => recentTrackIds.Contains(t.Id)) .SelectResponse() .ToDictionaryAsync(t => t.Id, cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index c6a5d0a..455b443 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -7,7 +7,7 @@ using Musify.Application.Tracks.Responses; namespace Musify.Application.Tracks { - public record GetTrackByIdQuery(Guid TrackId) + public record GetTrackByIdQuery(Guid TrackId, long? ViewerId = null) : IQuery>; public class GetTrackByIdQueryHandler(IDatabase database) @@ -18,6 +18,7 @@ namespace Musify.Application.Tracks var track = await database.Tracks .AsNoTracking() .Active() + .VisibleTo(request.ViewerId) .Where(t => t.Id == request.TrackId) .SelectResponse() .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs b/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs index 217893e..115184a 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs @@ -18,18 +18,23 @@ namespace Musify.Application.Tracks .AsNoTracking() .Active() .Where(track => track.OwnerUserId == request.UserId) - .GroupBy(track => track.Audio.TranscodeStatus) - .Select(group => new { Status = group.Key, Count = group.Count() }) + .GroupBy(track => new { track.Audio.TranscodeStatus, track.IsPublished }) + .Select(group => new { group.Key.TranscodeStatus, group.Key.IsPublished, Count = group.Count() }) .ToListAsync(cancellationToken); - int CountOf(ProcessingStatus status) => counts.Where(c => c.Status == status).Sum(c => c.Count); + int CountOf(ProcessingStatus status) => counts.Where(c => c.TranscodeStatus == status).Sum(c => c.Count); + + var published = counts.Where(c => c.IsPublished).Sum(c => c.Count); + var total = counts.Sum(c => c.Count); return new TrackStatusCountsResponse( - counts.Sum(c => c.Count), + total, CountOf(ProcessingStatus.Pending), CountOf(ProcessingStatus.Processing), CountOf(ProcessingStatus.Completed), - CountOf(ProcessingStatus.Failed)); + CountOf(ProcessingStatus.Failed), + published, + total - published); } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index e8ed4e9..7a8359b 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -17,7 +17,8 @@ namespace Musify.Application.Tracks { var tracksQuery = database.Tracks .AsNoTracking() - .Active(); + .Active() + .WherePublished(); if (!string.IsNullOrWhiteSpace(request.Name)) { diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 78f29a0..0a68fc3 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -13,7 +13,9 @@ namespace Musify.Application.Tracks string? Name, int PageNumber, int PageSize, - IReadOnlyList? Statuses = null) + IReadOnlyList? Statuses = null, + long? ViewerId = null, + bool? IsPublished = null) : IQuery>>; public class GetTracksByUserIdQueryHandler(IDatabase database) @@ -26,6 +28,9 @@ namespace Musify.Application.Tracks .Active() .Where(track => track.OwnerUserId == request.UserId); + if (request.ViewerId != request.UserId) + tracksQuery = tracksQuery.WherePublished(); + if (!string.IsNullOrWhiteSpace(request.Name)) { var normalizedName = TextNormalizer.Normalize(request.Name); @@ -38,6 +43,9 @@ namespace Musify.Application.Tracks tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus)); } + if (request.IsPublished is { } isPublished) + tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished); + return await tracksQuery .OrderByDescending(track => track.CreatedAt) .ThenBy(track => track.Id) diff --git a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index 98fcad9..a5506bd 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -7,14 +7,17 @@ using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { + public record AudioUploadSpec(string FileType, string ContentType, long? ExpectedSizeBytes = null); + public record RequestTrackUploadUrlsCommand( long UserId, - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, + string? PictureFileType, + string? PictureContentType, + string? AudioFileType = null, + string? AudioContentType = null, long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null) + long? ExpectedAudioSizeBytes = null, + IReadOnlyList? Audios = null) : ICommand>; public class RequestTrackUploadUrlsCommandHandler( @@ -24,35 +27,75 @@ namespace Musify.Application.Tracks UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { + private const int PictureKeyGroup = 1; + public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) { + var audios = ResolveAudios(request); + if (audios.Count == 0) + return Error.Validation(description: "At least one audio file is required."); + + var hasPicture = request.PictureFileType != null && request.PictureContentType != null; var routes = trackConfiguration.Routes; - var issued = await issuer.IssueAsync( - request.UserId, - [ - new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), - new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) - ], - cancellationToken); + + var uploads = new List(); + if (hasPicture) + { + for (var index = 0; index < audios.Count; index++) + { + uploads.Add(new UploadRequest( + UploadIntentPurpose.TrackPicture, routes, request.PictureFileType!, request.PictureContentType!, + request.ExpectedPictureSizeBytes, PictureKeyGroup)); + } + } + + uploads.AddRange(audios.Select(audio => + new UploadRequest(UploadIntentPurpose.TrackAudio, routes, audio.FileType, audio.ContentType, audio.ExpectedSizeBytes))); + + var issued = await issuer.IssueAsync(request.UserId, uploads, cancellationToken); if (issued.IsError) return issued.Errors; - var picture = issued.Value[0]; - var audio = issued.Value[1]; + var pictures = hasPicture ? issued.Value.Take(audios.Count).ToList() : []; + var audioUploads = issued.Value.Skip(pictures.Count).ToList(); + + var items = audioUploads + .Select((audio, index) => new TrackAudioUpload( + audio.IntentId, + hasPicture ? pictures[index].IntentId : null, + audio.Key, + audio.ObjectName, + audio.ContentType, + audio.UploadUrl)) + .ToList(); + + var firstPicture = pictures.Count > 0 ? pictures[0] : null; + var firstAudio = audioUploads[0]; return new TrackUploadUrlsResponse( - picture.IntentId, - audio.IntentId, + firstPicture?.IntentId, + firstAudio.IntentId, storageConfiguration.Bucket, - picture.Key, - picture.ObjectName, - picture.ContentType, - picture.UploadUrl, - audio.Key, - audio.ObjectName, - audio.ContentType, - audio.UploadUrl, - uploadIntentConfiguration.UploadUrlExpiresInSeconds); + firstPicture?.Key, + firstPicture?.ObjectName, + firstPicture?.ContentType, + firstPicture?.UploadUrl, + firstAudio.Key, + firstAudio.ObjectName, + firstAudio.ContentType, + firstAudio.UploadUrl, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + items); + } + + private static IReadOnlyList ResolveAudios(RequestTrackUploadUrlsCommand request) + { + if (request.Audios is { Count: > 0 } audios) + return audios; + + return request.AudioFileType != null && request.AudioContentType != null + ? [new AudioUploadSpec(request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)] + : []; } } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/SetTracksPublishedResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/SetTracksPublishedResponse.cs new file mode 100644 index 0000000..f6acd8d --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/SetTracksPublishedResponse.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Tracks.Responses +{ + public record SetTracksPublishedResponse( + IReadOnlyList UpdatedTrackIds, + IReadOnlyList SkippedTrackIds); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 95dd13a..952f1da 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -14,7 +14,8 @@ namespace Musify.Application.Tracks.Responses DateTime UpdatedAt, long OwnerUserId, IReadOnlyCollection Tags, - bool IsExplicit) + bool IsExplicit, + bool IsPublished) { public static TrackApplicationResponse FromEntity(Track track, int listensCount) => new( @@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses track.UpdatedAt, track.OwnerUserId, track.Tags.Select(tag => tag.Tag).ToList(), - track.IsExplicit); + track.IsExplicit, + track.IsPublished); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs index e4dc924..398bfcd 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -8,6 +8,12 @@ namespace Musify.Application.Tracks.Responses public static IQueryable Active(this IQueryable query) => query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); + public static IQueryable WherePublished(this IQueryable query) => + query.Where(track => track.IsPublished); + + public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => + query.Where(track => track.IsPublished || track.OwnerUserId == viewerId); + public static IQueryable SelectResponse(this IQueryable query) => query.Select(track => new TrackApplicationResponse( track.Id, @@ -20,7 +26,8 @@ namespace Musify.Application.Tracks.Responses track.UpdatedAt, track.OwnerUserId, track.Tags.Select(tag => tag.Tag).ToList(), - track.IsExplicit)); + track.IsExplicit, + track.IsPublished)); public static IQueryable SelectUserResponse(this IQueryable query) => query.Select(track => new UserTrackApplicationResponse( @@ -35,6 +42,7 @@ namespace Musify.Application.Tracks.Responses track.OwnerUserId, track.Tags.Select(tag => tag.Tag).ToList(), track.IsExplicit, + track.IsPublished, track.AlbumTracks .Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active) .OrderBy(albumTrack => albumTrack.AddedAt) diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs index 97a1f12..582c225 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs @@ -5,5 +5,7 @@ namespace Musify.Application.Tracks.Responses int Pending, int Processing, int Completed, - int Failed); + int Failed, + int Published, + int Unpublished); } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs index 8e1f5fb..2736e9c 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs @@ -1,16 +1,25 @@ namespace Musify.Application.Tracks.Responses { + public record TrackAudioUpload( + Guid IntentId, + Guid? PictureIntentId, + string Key, + string Name, + string ContentType, + string UploadUrl); + public record TrackUploadUrlsResponse( - Guid PictureIntentId, + Guid? PictureIntentId, Guid AudioIntentId, string Bucket, - string PictureKey, - string PictureName, - string PictureContentType, - string PictureUploadUrl, + string? PictureKey, + string? PictureName, + string? PictureContentType, + string? PictureUploadUrl, string AudioKey, string AudioName, string AudioContentType, string AudioUploadUrl, - int ExpiresInSeconds); + int ExpiresInSeconds, + IReadOnlyList Audios); } diff --git a/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs index 2fde813..575e04a 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs @@ -16,5 +16,6 @@ namespace Musify.Application.Tracks.Responses long OwnerUserId, IReadOnlyCollection Tags, bool IsExplicit, + bool IsPublished, IReadOnlyList Albums); } diff --git a/backend/Core/Musify.Application/Tracks/SetTrackPublished.cs b/backend/Core/Musify.Application/Tracks/SetTrackPublished.cs new file mode 100644 index 0000000..5c6aa5d --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/SetTrackPublished.cs @@ -0,0 +1,38 @@ +using ErrorOr; +using Mediator; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; + +namespace Musify.Application.Tracks +{ + public record SetTrackPublishedCommand(long UserId, Guid TrackId, bool IsPublished) + : ICommand>; + + public class SetTrackPublishedCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(SetTrackPublishedCommand request, CancellationToken cancellationToken) + { + var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + + var track = found.Value; + if (request.IsPublished && !track.Audio.IsProcessed) + return Error.Validation("Track.NotProcessed", "Only tracks whose audio finished processing can be published."); + + if (track.IsPublished == request.IsPublished) + return Result.Success; + + track.IsPublished = request.IsPublished; + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("{Action} track {TrackId}", request.IsPublished ? "Published" : "Unpublished", track.Id); + + return Result.Success; + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/SetTracksPublished.cs b/backend/Core/Musify.Application/Tracks/SetTracksPublished.cs new file mode 100644 index 0000000..51371a1 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/SetTracksPublished.cs @@ -0,0 +1,58 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks +{ + public record SetTracksPublishedCommand(long UserId, IReadOnlyList TrackIds, bool IsPublished) + : ICommand>; + + public class SetTracksPublishedCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(SetTracksPublishedCommand request, CancellationToken cancellationToken) + { + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .ToListAsync(cancellationToken); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + + var updated = new List(); + var skipped = new List(); + + foreach (var trackId in requestedIds) + { + var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId); + if (track == null || (request.IsPublished && !track.Audio.IsProcessed)) + { + skipped.Add(trackId); + continue; + } + + track.IsPublished = request.IsPublished; + updated.Add(trackId); + } + + if (updated.Count > 0) + { + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("{Action} {Count} tracks of user {UserId}", + request.IsPublished ? "Published" : "Unpublished", updated.Count, request.UserId); + } + + return new SetTracksPublishedResponse(updated, skipped); + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/StartListening.cs b/backend/Core/Musify.Application/Tracks/StartListening.cs index b49b344..607edc8 100644 --- a/backend/Core/Musify.Application/Tracks/StartListening.cs +++ b/backend/Core/Musify.Application/Tracks/StartListening.cs @@ -30,6 +30,7 @@ namespace Musify.Application.Tracks var track = await database.Tracks.AsNoTracking() .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) + .VisibleTo(request.UserId) .Select(t => new { t.Id, t.Audio }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index 8542293..682c277 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -22,6 +22,8 @@ namespace Musify.Domain.Entities public int? ReleaseYear { get; set; } + public AlbumType Type { get; set; } + public EntityPictures? Pictures { get; set; } public required long OwnerUserId { get; set; } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index e2a7c85..eb08b11 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -28,6 +28,8 @@ namespace Musify.Domain.Entities public bool IsExplicit { get; set; } + public bool IsPublished { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumType.cs b/backend/Core/Musify.Domain/ValueObjects/AlbumType.cs new file mode 100644 index 0000000..ecb59cb --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/AlbumType.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Musify.Domain.ValueObjects +{ + public enum AlbumType + { + Album = 0, + EP = 1, + [SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "Part of the public API contract.")] + Single = 2 + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs index 9b88b0e..3896462 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs @@ -16,13 +16,16 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks { var arguments = executeContext.Arguments; - await publishEndpoint.Publish( - new UpdateTrackPictureEvent( - arguments.TrackId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Sizes), - executeContext.CancellationToken); + if (arguments.FinalPictureKey != null) + { + await publishEndpoint.Publish( + new UpdateTrackPictureEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Sizes), + executeContext.CancellationToken); + } await publishEndpoint.Publish( new UpdateTrackAudioEvent( diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs index e711bc8..e5f135c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -5,7 +5,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments public record PublishTrackProcessingEventsArguments( Guid TrackId, string Bucket, - string FinalPictureKey, + string? FinalPictureKey, string FinalAudioKey, string AudioDestinationFolderKey, ImageSizes Sizes); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index f8ba5ab..0711742 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -11,11 +11,21 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) { - var builder = RoutingSlips.Create(correlationId) - .AddStep( + var builder = RoutingSlips.Create(correlationId); + + var hasPicture = message.PictureIntentId != null + && message.PictureSourceKey != null + && message.PictureDestinationKey != null; + + if (hasPicture) + { + builder.AddStep( ActivityNames.CopyPictureToFinal, CopyFileInBucketActivity.ExecuteEndpointName, - new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) + new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey!, message.Bucket, message.PictureDestinationKey!)); + } + + builder .AddStep( ActivityNames.CopyAudioToFinal, CopyFileInBucketActivity.ExecuteEndpointName, @@ -23,21 +33,23 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders .AddStep( ActivityNames.ConsumeUploadIntents, ConsumeUploadIntentsActivity.ExecuteEndpointName, - new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) + new ConsumeUploadIntentsArguments( + hasPicture ? [message.PictureIntentId!.Value, message.AudioIntentId] : [message.AudioIntentId])) .AddStep( ActivityNames.PublishTrackProcessingEvents, PublishTrackProcessingEventsActivity.ExecuteEndpointName, new PublishTrackProcessingEventsArguments( message.TrackId, message.Bucket, - message.PictureDestinationKey, + hasPicture ? message.PictureDestinationKey : null, message.AudioDestinationKey, message.AudioProcessedFolderKey, message.Sizes)) .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); - builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); + if (hasPicture) + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey!); builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); return builder; diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs index da99504..d91439c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs @@ -47,7 +47,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); } - if (track.Pictures.IsProcessed) + if (track.Pictures.IsProcessed && !string.IsNullOrEmpty(track.Pictures.OriginalName)) { builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs index 70c376a..d6c7935 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -48,6 +48,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas context.Saga.UpdatedAt = DateTime.UtcNow; context.Saga.Bucket = context.Message.Bucket; context.Saga.PictureKey = context.Message.PictureDestinationKey; + context.Saga.PictureProcessed = context.Message.PictureDestinationKey == null; context.Saga.AudioKey = context.Message.AudioDestinationKey; }) .TransitionTo(Processing)); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.Designer.cs new file mode 100644 index 0000000..7583940 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.Designer.cs @@ -0,0 +1,1090 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260924231849_TrackPublicationAndAlbumType")] + partial class TrackPublicationAndAlbumType + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "none", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint") + .HasColumnName("UserId"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany("PlayLists") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.EntityPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.cs new file mode 100644 index 0000000..3c86526 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924231849_TrackPublicationAndAlbumType.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class TrackPublicationAndAlbumType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPublished", + table: "Tracks", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql("UPDATE \"Tracks\" SET \"IsPublished\" = TRUE;"); + + migrationBuilder.AddColumn( + name: "Type", + table: "Albums", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPublished", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "Type", + table: "Albums"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 2208575..b341083 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -224,6 +224,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("Type") + .HasColumnType("integer"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -435,6 +438,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("IsExplicit") .HasColumnType("boolean"); + b.Property("IsPublished") + .HasColumnType("boolean"); + b.Property("LifeCycleStatus") .HasColumnType("integer"); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs index e8e8852..e1c0b67 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs @@ -1,8 +1,12 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Api.DataTransferObjects.Albums { public record CreateAlbumRequest( string Title, string? Description, int? ReleaseYear, - Guid PictureIntentId); + Guid PictureIntentId, + AlbumType Type = AlbumType.Album, + IReadOnlyList? TrackIds = null); } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs index cebbfac..0089991 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -1,7 +1,10 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Api.DataTransferObjects.Albums { public record UpdateAlbumRequest( string Title, string? Description, - int? ReleaseYear); + int? ReleaseYear, + AlbumType? Type = null); } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs index 2180bec..86db0ff 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -4,7 +4,7 @@ namespace Musify.Api.DataTransferObjects.Tracks { public record CreateTrackRequest( string Title, - Guid PictureIntentId, + Guid? PictureIntentId, Guid AudioIntentId, IReadOnlyCollection Tags, bool IsExplicit = false); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs index dc7b887..2c3ab94 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs @@ -1,10 +1,16 @@ namespace Musify.Api.DataTransferObjects.Tracks { + public record AudioUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); + public record RequestTrackUploadUrlsRequest( - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, + string? PictureFileType = null, + string? PictureContentType = null, + string? AudioFileType = null, + string? AudioContentType = null, long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null); + long? ExpectedAudioSizeBytes = null, + IReadOnlyList? Audios = null); } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTrackPublishedRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTrackPublishedRequest.cs new file mode 100644 index 0000000..282df88 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTrackPublishedRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record SetTrackPublishedRequest(bool IsPublished); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTracksPublishedRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTracksPublishedRequest.cs new file mode 100644 index 0000000..903aa00 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/SetTracksPublishedRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record SetTracksPublishedRequest(IReadOnlyList TrackIds, bool IsPublished); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index c56d647..f35468c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -84,12 +84,13 @@ namespace Musify.Api.Endpoints group.MapPost("/", CreateAlbum) .WithName("CreateAlbum") - .WithSummary("Create a new album") + .WithSummary("Create a new album, optionally with some of your tracks already in it") .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status201Created) .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapPut("/{id:guid}", UpdateAlbum) @@ -245,7 +246,8 @@ namespace Musify.Api.Endpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + new CreateAlbumCommand( + currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId, request.Type, request.TrackIds), cancellationToken); return result.ToCreatedResult(album => $"/albums/{album.Id}"); @@ -259,7 +261,7 @@ namespace Musify.Api.Endpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear), + new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.Type), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 4d2561a..791ecae 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -59,7 +59,7 @@ namespace Musify.Api.Endpoints group.MapGet("/users/{userId:long}", GetTracksByUserId) .AddEndpointFilter>() .WithName("GetTracksByUserId") - .WithSummary("Get paginated tracks for a user, optionally filtered by name and audio processing status. Each track lists the albums it belongs to") + .WithSummary("Get paginated tracks for a user, optionally filtered by name, audio processing status and published state. Each track lists the albums it belongs to. Only the owner sees unpublished tracks") .Produces>(); group.MapGet("/users/{userId:long}/status-counts", GetTrackStatusCounts) @@ -72,7 +72,7 @@ namespace Musify.Api.Endpoints group.MapPost("/", CreateTrack) .WithName("CreateTrack") - .WithSummary("Create a new track") + .WithSummary("Create a new track, unpublished, from upload intents. The picture is optional") .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status201Created) @@ -101,9 +101,29 @@ namespace Musify.Api.Endpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); + group.MapPut("/{id:guid}/published", SetTrackPublished) + .WithName("SetTrackPublished") + .WithSummary("Publish or unpublish one of your tracks. Only tracks with completed audio processing can be published") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + group.MapPut("/published", SetTracksPublished) + .WithName("SetTracksPublished") + .WithSummary("Publish or unpublish several of your tracks at once. Missing tracks and tracks that cannot be published yet are returned as skipped") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + group.MapPost("/upload-urls", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") - .WithSummary("Request pre-signed URLs to upload track picture and audio") + .WithSummary("Request pre-signed URLs to upload the audio of one or many tracks and an optional picture shared by all of them") .AddEndpointFilter>() .RequireAuthorization() .Produces() @@ -128,10 +148,11 @@ namespace Musify.Api.Endpoints private static async Task GetTrackById( IMediator mediator, + CurrentUser currentUser, Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); + var result = await mediator.Send(new GetTrackByIdQuery(id, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -171,14 +192,39 @@ namespace Musify.Api.Endpoints private static async Task GetTracksByUserId( IMediator mediator, + CurrentUser currentUser, long userId, string? name, [AsParameters] PageQuery page, CancellationToken cancellationToken, - ProcessingStatus[]? status = null) + ProcessingStatus[]? status = null, + bool? published = null) { var result = await mediator.Send( - new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status), + new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status, currentUser.Id, published), + cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SetTrackPublished( + IMediator mediator, + CurrentUser currentUser, + Guid id, + SetTrackPublishedRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SetTrackPublishedCommand(currentUser.RequiredId, id, request.IsPublished), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SetTracksPublished( + IMediator mediator, + CurrentUser currentUser, + SetTracksPublishedRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new SetTracksPublishedCommand(currentUser.RequiredId, request.TrackIds, request.IsPublished), cancellationToken); return result.ToHttpResult(); } @@ -243,7 +289,8 @@ namespace Musify.Api.Endpoints request.AudioFileType, request.AudioContentType, request.ExpectedPictureSizeBytes, - request.ExpectedAudioSizeBytes), + request.ExpectedAudioSizeBytes, + request.Audios?.Select(audio => new AudioUploadSpec(audio.FileType, audio.ContentType, audio.ExpectedSizeBytes)).ToList()), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs index f65887d..324c1a1 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs @@ -5,6 +5,8 @@ namespace Musify.Api.Validators.Albums { public sealed class CreateAlbumRequestValidator : AbstractValidator { + public const int MaxTracksOnCreate = 500; + public CreateAlbumRequestValidator() { RuleFor(x => x.Title) @@ -20,6 +22,18 @@ namespace Musify.Api.Validators.Albums RuleFor(x => x.PictureIntentId) .NotEmpty(); + + RuleFor(x => x.Type) + .IsInEnum(); + + RuleFor(x => x.TrackIds) + .Must(ids => ids!.Count <= MaxTracksOnCreate) + .WithMessage($"At most {MaxTracksOnCreate} tracks can be added at once.") + .When(x => x.TrackIds != null); + + RuleForEach(x => x.TrackIds) + .NotEmpty() + .When(x => x.TrackIds != null); } } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs index e9b6458..04e7175 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs @@ -17,6 +17,10 @@ namespace Musify.Api.Validators.Albums RuleFor(x => x.ReleaseYear) .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) .When(x => x.ReleaseYear.HasValue); + + RuleFor(x => x.Type) + .IsInEnum() + .When(x => x.Type.HasValue); } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs index 2cbad2e..9f7f34b 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -13,7 +13,8 @@ namespace Musify.Api.Validators.Tracks .MaximumLength(Limits.TrackTitle); RuleFor(x => x.PictureIntentId) - .NotEmpty(); + .NotEqual(Guid.Empty) + .When(x => x.PictureIntentId.HasValue); RuleFor(x => x.AudioIntentId) .NotEmpty(); diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs index 3e78902..9138aed 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs @@ -6,25 +6,53 @@ namespace Musify.Api.Validators.Tracks { public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator { + public const int MaxAudiosPerRequest = 20; + public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) { - RuleFor(x => x.PictureFileType) - .MustBeFileType(Uploads.PictureFileTypes); + When(x => x.PictureFileType != null || x.PictureContentType != null, () => + { + RuleFor(x => x.PictureFileType!) + .MustBeFileType(Uploads.PictureFileTypes); - RuleFor(x => x.PictureContentType) - .MustBeContentType(Uploads.PictureContentTypes); + RuleFor(x => x.PictureContentType!) + .MustBeContentType(Uploads.PictureContentTypes); - RuleFor(x => x.AudioFileType) - .MustBeFileType(Uploads.AudioFileTypes); + RuleFor(x => x.ExpectedPictureSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + }); - RuleFor(x => x.AudioContentType) - .MustBeContentType(Uploads.AudioContentTypes); + When(x => x.Audios is { Count: > 0 }, () => + { + RuleFor(x => x.AudioFileType) + .Null() + .WithMessage("Use either the audio fields or the audios list, not both."); - RuleFor(x => x.ExpectedPictureSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.AudioContentType) + .Null() + .WithMessage("Use either the audio fields or the audios list, not both."); - RuleFor(x => x.ExpectedAudioSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.Audios!) + .Must(audios => audios.Count <= MaxAudiosPerRequest) + .WithMessage($"At most {MaxAudiosPerRequest} audio files can be requested at once."); + + RuleForEach(x => x.Audios!).ChildRules(audio => + { + audio.RuleFor(a => a.FileType).MustBeFileType(Uploads.AudioFileTypes); + audio.RuleFor(a => a.ContentType).MustBeContentType(Uploads.AudioContentTypes); + audio.RuleFor(a => a.ExpectedSizeBytes).MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + }); + }).Otherwise(() => + { + RuleFor(x => x.AudioFileType!) + .MustBeFileType(Uploads.AudioFileTypes); + + RuleFor(x => x.AudioContentType!) + .MustBeContentType(Uploads.AudioContentTypes); + + RuleFor(x => x.ExpectedAudioSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + }); } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/SetTracksPublishedRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/SetTracksPublishedRequestValidator.cs new file mode 100644 index 0000000..3b7ac79 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/SetTracksPublishedRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; + +namespace Musify.Api.Validators.Tracks +{ + public sealed class SetTracksPublishedRequestValidator : AbstractValidator + { + public const int MaxTracksPerRequest = 100; + + public SetTracksPublishedRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be updated at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } + } +} diff --git a/backend/Hosts/Musify.Api/appsettings.json b/backend/Hosts/Musify.Api/appsettings.json index c981d4a..1dd9747 100644 --- a/backend/Hosts/Musify.Api/appsettings.json +++ b/backend/Hosts/Musify.Api/appsettings.json @@ -118,8 +118,8 @@ }, "UploadIntent": { "UploadUrlExpiresInSeconds": 120, - "MaxActiveUploadIntentsPerUser": 5, - "MaxActiveUploadBytesPerUser": 209715200, + "MaxActiveUploadIntentsPerUser": 50, + "MaxActiveUploadBytesPerUser": 1073741824, "MaxUploadBytes": 104857600, "DefaultExpectedPictureSizeBytes": 10485760, "DefaultExpectedAudioSizeBytes": 104857600, diff --git a/backend/Hosts/Musify.Worker/appsettings.json b/backend/Hosts/Musify.Worker/appsettings.json index d4fccda..4164515 100644 --- a/backend/Hosts/Musify.Worker/appsettings.json +++ b/backend/Hosts/Musify.Worker/appsettings.json @@ -114,8 +114,8 @@ }, "UploadIntent": { "UploadUrlExpiresInSeconds": 120, - "MaxActiveUploadIntentsPerUser": 5, - "MaxActiveUploadBytesPerUser": 209715200, + "MaxActiveUploadIntentsPerUser": 50, + "MaxActiveUploadBytesPerUser": 1073741824, "MaxUploadBytes": 104857600, "DefaultExpectedPictureSizeBytes": 10485760, "DefaultExpectedAudioSizeBytes": 104857600, diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 937936e..8f3fad5 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -1,5 +1,7 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Tests.TestSupport; @@ -14,6 +16,11 @@ namespace Musify.Api.Tests.Endpoints [Collection(ApiCollection.Name)] public sealed class AlbumEndpointsTests(ApiTestFixture fixture) { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + private Task CreateUserAsync() => fixture.SeedUserAsync(); private async Task CreatePictureIntentAsync(HttpClient client) @@ -54,7 +61,7 @@ namespace Musify.Api.Tests.Endpoints var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); Assert.NotNull(body); Assert.Equal("My Album", body.Title); Assert.Equal(userId, body.OwnerUserId); @@ -89,7 +96,7 @@ namespace Musify.Api.Tests.Endpoints var client = fixture.CreateAuthenticatedClient(userId); var pictureIntentId = await CreatePictureIntentAsync(client); var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + .Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); @@ -104,7 +111,7 @@ namespace Musify.Api.Tests.Endpoints var ownerClient = fixture.CreateAuthenticatedClient(owner); var pictureIntentId = await CreatePictureIntentAsync(ownerClient); var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + .Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); @@ -119,7 +126,7 @@ namespace Musify.Api.Tests.Endpoints var ownerClient = fixture.CreateAuthenticatedClient(owner); var pictureIntentId = await CreatePictureIntentAsync(ownerClient); var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + .Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( $"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.NewGuid()), TestContext.Current.CancellationToken); @@ -134,7 +141,7 @@ namespace Musify.Api.Tests.Endpoints var ownerClient = fixture.CreateAuthenticatedClient(owner); var pictureIntentId = await CreatePictureIntentAsync(ownerClient); var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + .Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); var response = await ownerClient.PutAsJsonAsync( $"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.Empty), TestContext.Current.CancellationToken); @@ -149,7 +156,7 @@ namespace Musify.Api.Tests.Endpoints var client = fixture.CreateAuthenticatedClient(userId); var pictureIntentId = await CreatePictureIntentAsync(client); var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + .Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index 4cf19de..d0c88f6 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -34,6 +34,7 @@ namespace Musify.Api.Tests.Endpoints NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = owner.Id, Owner = owner, + IsPublished = true, Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] }; foreach (var tag in track.Tags) diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs index 3447fc5..968656d 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs @@ -19,7 +19,8 @@ namespace Musify.Api.Tests.Endpoints private async Task SeedTrackAsync( long ownerId, string title, - ProcessingStatus status = ProcessingStatus.Completed) + ProcessingStatus status = ProcessingStatus.Completed, + bool isPublished = true) { using var scope = fixture.Services.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); @@ -28,6 +29,7 @@ namespace Musify.Api.Tests.Endpoints Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = ownerId, + IsPublished = isPublished, Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null } }; database.Tracks.Add(track); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/TrackPublicationEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/TrackPublicationEndpointsTests.cs new file mode 100644 index 0000000..470c13a --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/TrackPublicationEndpointsTests.cs @@ -0,0 +1,298 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.DataTransferObjects; +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using NSubstitute; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class TrackPublicationEndpointsTests(ApiTestFixture fixture) + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task SeedTrackAsync( + long ownerId, + string title, + ProcessingStatus status = ProcessingStatus.Completed, + bool isPublished = true) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = ownerId, + IsPublished = isPublished, + Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null } + }; + database.Tracks.Add(track); + await database.SaveChangesAsync(Ct); + return track.Id; + } + + private async Task SeedAlbumWithTracksAsync(long ownerId, params Guid[] trackIds) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var album = new Album { Title = "Album", NormalizedTitle = "ALBUM", OwnerUserId = ownerId }; + database.Albums.Add(album); + var number = 1; + foreach (var trackId in trackIds) + database.AlbumHasTracks.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = trackId, TrackNumber = number++ }); + await database.SaveChangesAsync(Ct); + return album.Id; + } + + private void MockStorage() + { + fixture.StorageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + fixture.StorageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + } + + private static int Number(JsonElement element) => + element.ValueKind == JsonValueKind.String + ? int.Parse(element.GetString()!, CultureInfo.InvariantCulture) + : element.GetInt32(); + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var text = await response.Content.ReadAsStringAsync(Ct); + return JsonDocument.Parse(text).RootElement.Clone(); + } + + private static string UserPath(long userId) => $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}"; + + [Fact] + public async Task PutPublished_Owner_PublishesTheTrackForEveryone() + { + var userId = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(userId, "Hidden", isPublished: false); + var anonymous = fixture.CreateAnonymousClient(); + var before = await anonymous.GetAsync($"/tracks/{trackId}", Ct); + + var publish = await fixture.CreateAuthenticatedClient(userId) + .PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct); + var after = await anonymous.GetAsync($"/tracks/{trackId}", Ct); + + Assert.Equal(HttpStatusCode.NotFound, before.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, publish.StatusCode); + Assert.Equal(HttpStatusCode.OK, after.StatusCode); + Assert.True((await ReadJsonAsync(after)).GetProperty("isPublished").GetBoolean()); + } + + [Fact] + public async Task PutPublished_AudioNotProcessed_ReturnsBadRequest() + { + var userId = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Processing, isPublished: false); + + var response = await fixture.CreateAuthenticatedClient(userId) + .PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PutPublished_NotTheOwnerOrAnonymous_IsRejected() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(owner, "Mine", isPublished: false); + + var asStranger = await fixture.CreateAuthenticatedClient(stranger) + .PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct); + var anonymous = await fixture.CreateAnonymousClient() + .PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, asStranger.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + } + + [Fact] + public async Task PutPublishedBatch_UpdatesProcessedTracksAndReportsTheSkippedOnes() + { + var userId = await fixture.SeedUserAsync(); + var ready = await SeedTrackAsync(userId, "Ready", isPublished: false); + var busy = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false); + + var response = await fixture.CreateAuthenticatedClient(userId) + .PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([ready, busy], true), Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + Assert.Equal([ready], body.GetProperty("updatedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList()); + Assert.Equal([busy], body.GetProperty("skippedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList()); + } + + [Fact] + public async Task PutPublishedBatch_EmptyList_ReturnsBadRequest() + { + var userId = await fixture.SeedUserAsync(); + + var response = await fixture.CreateAuthenticatedClient(userId) + .PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([], true), Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetUserTracks_UnpublishedTracksOnlyReachTheOwner() + { + var userId = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + await SeedTrackAsync(userId, "Live"); + await SeedTrackAsync(userId, "Hidden", isPublished: false); + var path = UserPath(userId); + + var anonymous = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync(path, Ct)); + var asStranger = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(stranger).GetAsync(path, Ct)); + var asOwner = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync(path, Ct)); + var onlyHidden = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync($"{path}?published=false", Ct)); + + Assert.Equal(1, Number(anonymous.GetProperty("totalItemCount"))); + Assert.Equal(1, Number(asStranger.GetProperty("totalItemCount"))); + Assert.Equal(2, Number(asOwner.GetProperty("totalItemCount"))); + Assert.Equal("Hidden", onlyHidden.GetProperty("items")[0].GetProperty("title").GetString()); + Assert.False(onlyHidden.GetProperty("items")[0].GetProperty("isPublished").GetBoolean()); + } + + [Fact] + public async Task StatusCounts_SplitPublishedFromUnpublished() + { + var userId = await fixture.SeedUserAsync(); + await SeedTrackAsync(userId, "Live"); + await SeedTrackAsync(userId, "Hidden", isPublished: false); + await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false); + + var response = await fixture.CreateAuthenticatedClient(userId).GetAsync($"{UserPath(userId)}/status-counts", Ct); + + var body = await ReadJsonAsync(response); + Assert.Equal(1, Number(body.GetProperty("published"))); + Assert.Equal(2, Number(body.GetProperty("unpublished"))); + } + + [Fact] + public async Task Search_AndStream_IgnoreUnpublishedTracks() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var title = $"Secret{Guid.NewGuid():N}"; + var trackId = await SeedTrackAsync(owner, title, isPublished: false); + + var search = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks?name={title}", Ct)); + var stream = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/tracks/{trackId}/stream", Ct); + + Assert.Equal(0, Number(search.GetProperty("totalItemCount"))); + Assert.Equal(HttpStatusCode.NotFound, stream.StatusCode); + } + + [Fact] + public async Task GetAlbum_HidesUnpublishedTracksFromTheListAndTheCount() + { + var userId = await fixture.SeedUserAsync(); + var live = await SeedTrackAsync(userId, "Live"); + var hidden = await SeedTrackAsync(userId, "Hidden", isPublished: false); + var albumId = await SeedAlbumWithTracksAsync(userId, hidden, live); + + var tracks = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}/tracks", Ct)); + var album = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}", Ct)); + + Assert.Equal(1, Number(tracks.GetProperty("totalItemCount"))); + Assert.Equal(1, Number(album.GetProperty("trackCount"))); + Assert.Equal("Album", album.GetProperty("type").GetString()); + } + + [Fact] + public async Task PostUploadUrls_ManyAudiosAndSharedPicture_ReturnsAnIntentPerFile() + { + var userId = await fixture.SeedUserAsync(); + MockStorage(); + var request = new RequestTrackUploadUrlsRequest( + "webp", "image/webp", Audios: [new("mp3", "audio/mpeg", 1000), new("flac", "audio/flac", 2000)]); + + var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync("/tracks/upload-urls", request, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + var audios = body.GetProperty("audios").EnumerateArray().ToList(); + Assert.Equal(2, audios.Count); + Assert.All(audios, audio => Assert.NotEqual(JsonValueKind.Null, audio.GetProperty("pictureIntentId").ValueKind)); + Assert.Equal(2, audios.Select(audio => audio.GetProperty("intentId").GetGuid()).Distinct().Count()); + Assert.Equal(2, audios.Select(audio => audio.GetProperty("pictureIntentId").GetGuid()).Distinct().Count()); + } + + [Fact] + public async Task PostUploadUrls_AudiosOnly_HasNoPictureIntent() + { + var userId = await fixture.SeedUserAsync(); + MockStorage(); + + var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync( + "/tracks/upload-urls", new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]), Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + Assert.Equal(JsonValueKind.Null, body.GetProperty("pictureIntentId").ValueKind); + Assert.Equal(JsonValueKind.Null, body.GetProperty("audios")[0].GetProperty("pictureIntentId").ValueKind); + } + + [Fact] + public async Task PostAlbum_WithTypeAndTracks_LinksThemAndPutChangesTheType() + { + var userId = await fixture.SeedUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var first = await SeedTrackAsync(userId, "First"); + var second = await SeedTrackAsync(userId, "Second"); + MockStorage(); + var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct)); + var intentId = upload.GetProperty("intentId").GetGuid(); + + var created = await client.PostAsJsonAsync( + "/albums", new CreateAlbumRequest("Typed", null, 2024, intentId, AlbumType.EP, [second, first]), Ct); + + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var album = await ReadJsonAsync(created); + Assert.Equal("EP", album.GetProperty("type").GetString()); + Assert.Equal(2, Number(album.GetProperty("trackCount"))); + var albumId = album.GetProperty("id").GetGuid(); + + var ids = await ReadJsonAsync(await client.GetAsync($"/albums/{albumId}/tracks/ids", Ct)); + Assert.Equal([second, first], ids.GetProperty("trackIds").EnumerateArray().Select(e => e.GetGuid()).ToList()); + + var updated = await client.PutAsJsonAsync($"/albums/{albumId}", new UpdateAlbumRequest("Typed", null, 2024, AlbumType.Single), Ct); + Assert.Equal("Single", (await ReadJsonAsync(updated)).GetProperty("type").GetString()); + } + + [Fact] + public async Task PostAlbum_WithTrackOfAnotherUser_ReturnsForbidden() + { + var userId = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var theirs = await SeedTrackAsync(stranger, "Theirs"); + var client = fixture.CreateAuthenticatedClient(userId); + MockStorage(); + var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct)); + + var response = await client.PostAsJsonAsync( + "/albums", new CreateAlbumRequest("Stolen", null, null, upload.GetProperty("intentId").GetGuid(), TrackIds: [theirs]), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index 43498e2..cde1e43 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -31,7 +31,8 @@ namespace Musify.Api.Tests.Endpoints Title = trackTitle, NormalizedTitle = trackTitle.ToUpperInvariant(), OwnerUserId = owner.Id, - Owner = owner + Owner = owner, + IsPublished = true }; database.Tracks.Add(track); database.ListeningHistories.Add(new ListeningHistory diff --git a/backend/Tests/Musify.Api.Tests/Validators/LibraryRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/LibraryRequestValidatorTests.cs new file mode 100644 index 0000000..6ad192c --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/LibraryRequestValidatorTests.cs @@ -0,0 +1,122 @@ +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Validators.Albums; +using Musify.Api.Validators.Tracks; +using Musify.Application.Configuration; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Api.Tests.Validators +{ + public sealed class LibraryRequestValidatorTests + { + private readonly UploadIntentConfiguration _configuration = new(); + + [Fact] + public void CreateTrack_WithoutPicture_Passes() + { + var request = new CreateTrackRequest("Song", null, Guid.NewGuid(), [Genre.Pop]); + + Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void CreateTrack_EmptyPictureIntent_Fails() + { + var request = new CreateTrackRequest("Song", Guid.Empty, Guid.NewGuid(), [Genre.Pop]); + + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void UploadUrls_ManyAudiosWithSharedPicture_Passes() + { + var request = new RequestTrackUploadUrlsRequest( + "png", "image/png", Audios: [new("mp3", "audio/mpeg", 10), new("flac", "audio/flac")]); + + Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid); + } + + [Fact] + public void UploadUrls_AudiosWithoutPicture_Passes() + { + var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]); + + Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid); + } + + [Fact] + public void UploadUrls_NoAudioAtAll_Fails() + { + Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest("png", "image/png")).IsValid); + } + + [Fact] + public void UploadUrls_LegacyAudioTogetherWithAudiosList_Fails() + { + var request = new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", Audios: [new("mp3", "audio/mpeg")]); + + Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid); + } + + [Fact] + public void UploadUrls_HalfAPicture_Fails() + { + var request = new RequestTrackUploadUrlsRequest("png", null, Audios: [new("mp3", "audio/mpeg")]); + + Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid); + } + + [Fact] + public void UploadUrls_InvalidAudioInTheList_Fails() + { + var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg"), new("png", "image/png")]); + + Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid); + } + + [Fact] + public void UploadUrls_TooManyAudios_Fails() + { + var audios = Enumerable.Range(0, RequestTrackUploadUrlsRequestValidator.MaxAudiosPerRequest + 1) + .Select(_ => new AudioUploadRequest("mp3", "audio/mpeg")) + .ToList(); + + Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest(Audios: audios)).IsValid); + } + + [Fact] + public void SetTracksPublished_RequiresBetweenOneAndTheMaximumTracks() + { + var validator = new SetTracksPublishedRequestValidator(); + var tooMany = Enumerable.Range(0, SetTracksPublishedRequestValidator.MaxTracksPerRequest + 1).Select(_ => Guid.NewGuid()).ToList(); + + Assert.True(validator.Validate(new SetTracksPublishedRequest([Guid.NewGuid()], true)).IsValid); + Assert.False(validator.Validate(new SetTracksPublishedRequest([], true)).IsValid); + Assert.False(validator.Validate(new SetTracksPublishedRequest(tooMany, true)).IsValid); + Assert.False(validator.Validate(new SetTracksPublishedRequest([Guid.Empty], true)).IsValid); + } + + [Fact] + public void CreateAlbum_UnknownTypeOrTooManyTracks_Fails() + { + var validator = new CreateAlbumRequestValidator(); + var tooMany = Enumerable.Range(0, CreateAlbumRequestValidator.MaxTracksOnCreate + 1).Select(_ => Guid.NewGuid()).ToList(); + + Assert.True(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), AlbumType.EP, [Guid.NewGuid()])).IsValid); + Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), (AlbumType)42)).IsValid); + Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: tooMany)).IsValid); + Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: [Guid.Empty])).IsValid); + } + + [Fact] + public void UpdateAlbum_TypeIsOptionalButMustBeKnown() + { + var validator = new UpdateAlbumRequestValidator(); + + Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null)).IsValid); + Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null, AlbumType.Single)).IsValid); + Assert.False(validator.Validate(new UpdateAlbumRequest("Album", null, null, (AlbumType)42)).IsValid); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumWithTracksAndTypeTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumWithTracksAndTypeTests.cs new file mode 100644 index 0000000..abc37c0 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumWithTracksAndTypeTests.cs @@ -0,0 +1,142 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class CreateAlbumWithTracksAndTypeTests : HandlerTestBase + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly IEventBus _eventBus = Substitute.For(); + private readonly IStorageService _storageService = Substitute.For(); + + private CreateAlbumCommandHandler CreateHandler() + { + _storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); + + return new CreateAlbumCommandHandler( + _eventBus, + Database, + new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Album()); + } + + private static UploadIntent PictureIntent(long userId) => + TestEntities.UploadIntent(userId, UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); + + [Theory] + [InlineData(AlbumType.Album)] + [InlineData(AlbumType.EP)] + [InlineData(AlbumType.Single)] + public async Task Handle_StoresAndReturnsTheAlbumType(AlbumType type) + { + var owner = TestEntities.User(); + var intent = PictureIntent(owner.Id); + await SeedAsync(owner, intent); + + var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Typed", null, null, intent.Id, type), Ct); + + Assert.False(result.IsError); + Assert.Equal(type, result.Value.Type); + Assert.Equal(type, (await Database.Albums.AsNoTracking().SingleAsync(Ct)).Type); + } + + [Fact] + public async Task Handle_DefaultsToAlbumType() + { + var owner = TestEntities.User(); + var intent = PictureIntent(owner.Id); + await SeedAsync(owner, intent); + + var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Default", null, null, intent.Id), Ct); + + Assert.Equal(AlbumType.Album, result.Value.Type); + } + + [Fact] + public async Task Handle_WithTrackIds_CreatesAlbumAndLinksTracksInOrderInOneSave() + { + var owner = TestEntities.User(); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second", isPublished: false); + var third = TestEntities.Track(owner, "Third"); + var intent = PictureIntent(owner.Id); + await SeedAsync(owner, first, second, third, intent); + + var result = await CreateHandler().Handle( + new CreateAlbumCommand(owner.Id, "With tracks", null, null, intent.Id, AlbumType.EP, [third.Id, first.Id, second.Id, third.Id]), Ct); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TrackCount); + Assert.Equal([third.Id, first.Id], result.Value.CoverTrackIds); + + var links = await Database.AlbumHasTracks.AsNoTracking() + .Where(link => link.AlbumId == result.Value.Id) + .OrderBy(link => link.TrackNumber) + .ToListAsync(Ct); + Assert.Equal([third.Id, first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([1, 2, 3], links.Select(link => link.TrackNumber)); + await _eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_TrackOfAnotherUser_ReturnsForbiddenAndCreatesNothing() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var theirs = TestEntities.Track(stranger, "Theirs"); + var intent = PictureIntent(owner.Id); + await SeedAsync(owner, stranger, theirs, intent); + + var result = await CreateHandler().Handle( + new CreateAlbumCommand(owner.Id, "Stolen", null, null, intent.Id, TrackIds: [theirs.Id]), Ct); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.Empty(await Database.Albums.ToListAsync(Ct)); + Assert.Empty(await Database.AlbumHasTracks.ToListAsync(Ct)); + await _eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_MissingTrack_ReturnsNotFoundAndCreatesNothing() + { + var owner = TestEntities.User(); + var intent = PictureIntent(owner.Id); + await SeedAsync(owner, intent); + + var result = await CreateHandler().Handle( + new CreateAlbumCommand(owner.Id, "Ghost", null, null, intent.Id, TrackIds: [Guid.NewGuid()]), Ct); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + Assert.Empty(await Database.Albums.ToListAsync(Ct)); + } + + [Fact] + public async Task Update_ChangesTypeOnlyWhenProvided() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + album.Type = AlbumType.EP; + await SeedAsync(owner, album); + var handler = new UpdateAlbumCommandHandler(Database, NoOpLogger()); + + var untouched = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024), Ct); + var changed = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024, AlbumType.Single), Ct); + + Assert.Equal(AlbumType.EP, untouched.Value.Type); + Assert.Equal(AlbumType.Single, changed.Value.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 1d1d104..cd68024 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -57,7 +57,8 @@ namespace Musify.Application.Tests.TestSupport TrackAudio? audio = null, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, IEnumerable? tags = null, - bool isExplicit = false) + bool isExplicit = false, + bool isPublished = true) { var track = new Track { @@ -66,6 +67,7 @@ namespace Musify.Application.Tests.TestSupport DurationSeconds = durationSeconds, LifeCycleStatus = lifeCycleStatus, IsExplicit = isExplicit, + IsPublished = isPublished, OwnerUserId = owner.Id, Owner = owner, Pictures = pictures ?? ProcessedPictures(), diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs index 3fa8a6d..bf774ad 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs @@ -11,9 +11,9 @@ namespace Musify.Application.Tests.Tracks { public sealed class DeleteTracksCommandHandlerTests : HandlerTestBase { - private readonly IEventBus eventBus = Substitute.For(); + private readonly IEventBus _eventBus = Substitute.For(); - private DeleteTracksCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + private DeleteTracksCommandHandler CreateHandler() => new(Database, _eventBus, NoOpLogger()); [Fact] public async Task Handle_OwnTracks_MarksAllAsRemovingAndPublishesOneEventEach() @@ -29,7 +29,7 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(2, result.Value.DeletedTrackIds.Count); Assert.Empty(result.Value.FailedTrackIds); Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Removing, track.LifeCycleStatus)); - await eventBus.Received(2).PublishAsync(Arg.Any(), Arg.Any()); + await _eventBus.Received(2).PublishAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -45,7 +45,7 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Active, track.LifeCycleStatus)); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + await _eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -75,7 +75,7 @@ namespace Musify.Application.Tests.Tracks var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [track.Id, track.Id]), TestContext.Current.CancellationToken); Assert.Single(result.Value.DeletedTrackIds); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + await _eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsBatchTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsBatchTests.cs new file mode 100644 index 0000000..3df12fb --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsBatchTests.cs @@ -0,0 +1,119 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class RequestTrackUploadUrlsBatchTests : HandlerTestBase + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly IStorageService _storageService = Substitute.For(); + + private RequestTrackUploadUrlsCommandHandler CreateHandler() + { + _storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => $"https://storage.musify.test/{call.ArgAt(1)}"); + + var config = TestConfigurations.UploadIntent(); + return new RequestTrackUploadUrlsCommandHandler( + new UploadIntentIssuer( + Database, + _storageService, + new UploadIntentValidator(Database, _storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.Storage(), + TestConfigurations.Track(), + config); + } + + private static readonly AudioUploadSpec[] _threeAudios = + [ + new("mp3", "audio/mpeg", 3_000_000), + new("flac", "audio/flac", 30_000_000), + new("wav", "audio/wav", 40_000_000) + ]; + + [Fact] + public async Task Handle_ManyAudiosAndSharedPicture_IssuesOneAudioIntentPerFileAndOnePictureIntentPerTrackOnASingleObject() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle( + new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", Audios: _threeAudios), Ct); + + Assert.False(result.IsError); + Assert.Equal(3, result.Value.Audios.Count); + Assert.All(result.Value.Audios, audio => Assert.NotNull(audio.PictureIntentId)); + + var intents = await Database.UploadIntents.AsNoTracking().ToListAsync(Ct); + var pictures = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackPicture).ToList(); + var audios = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackAudio).ToList(); + Assert.Equal(3, pictures.Count); + Assert.Equal(3, audios.Count); + + Assert.Single(pictures.Select(intent => intent.Key).Distinct()); + Assert.Equal(3, pictures.Select(intent => intent.ObjectName).Distinct().Count()); + Assert.Equal(3, audios.Select(intent => intent.Key).Distinct().Count()); + Assert.Equal( + result.Value.Audios.Select(audio => audio.PictureIntentId!.Value).Order(), + pictures.Select(intent => intent.Id).Order()); + Assert.Equal(result.Value.Audios.Select(audio => audio.IntentId).Order(), audios.Select(intent => intent.Id).Order()); + Assert.Equal(result.Value.Audios[0].IntentId, result.Value.AudioIntentId); + Assert.Equal(result.Value.Audios[0].PictureIntentId, result.Value.PictureIntentId); + Assert.Equal(pictures[0].Key, result.Value.PictureKey); + } + + [Fact] + public async Task Handle_ManyAudiosWithoutPicture_IssuesNoPictureIntents() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle( + new RequestTrackUploadUrlsCommand(user.Id, null, null, Audios: _threeAudios), Ct); + + Assert.False(result.IsError); + Assert.Null(result.Value.PictureIntentId); + Assert.Null(result.Value.PictureUploadUrl); + Assert.All(result.Value.Audios, audio => Assert.Null(audio.PictureIntentId)); + Assert.All(await Database.UploadIntents.ToListAsync(Ct), intent => Assert.Equal(UploadIntentPurpose.TrackAudio, intent.Purpose)); + } + + [Fact] + public async Task Handle_LegacyAudioFields_StillReturnASingleItem() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"), Ct); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Audios); + Assert.Equal(result.Value.AudioIntentId, item.IntentId); + Assert.Equal(result.Value.PictureIntentId, item.PictureIntentId); + } + + [Fact] + public async Task Handle_NoAudio_ReturnsValidationError() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp"), Ct); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(await Database.UploadIntents.ToListAsync(Ct)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/SetTrackPublishedCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/SetTrackPublishedCommandHandlerTests.cs new file mode 100644 index 0000000..b6f82ca --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/SetTrackPublishedCommandHandlerTests.cs @@ -0,0 +1,146 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class SetTrackPublishedCommandHandlerTests : HandlerTestBase + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private SetTrackPublishedCommandHandler CreateSingleHandler() => new(Database, NoOpLogger()); + + private SetTracksPublishedCommandHandler CreateBatchHandler() => new(Database, NoOpLogger()); + + private async Task IsPublishedAsync(Guid trackId) => + (await Database.Tracks.AsNoTracking().SingleAsync(t => t.Id == trackId, Ct)).IsPublished; + + [Fact] + public async Task Single_ProcessedTrack_CanBePublishedAndUnpublished() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, isPublished: false); + await SeedAsync(owner, track); + + var publish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct); + var published = await IsPublishedAsync(track.Id); + var unpublish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct); + + Assert.False(publish.IsError); + Assert.True(published); + Assert.False(unpublish.IsError); + Assert.False(await IsPublishedAsync(track.Id)); + } + + [Theory] + [InlineData(ProcessingStatus.Pending)] + [InlineData(ProcessingStatus.Processing)] + [InlineData(ProcessingStatus.Failed)] + public async Task Single_AudioNotCompleted_CannotBePublished(ProcessingStatus status) + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, audio: new Domain.ValueObjects.TrackAudio { TranscodeStatus = status }, isPublished: false); + await SeedAsync(owner, track); + + var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.False(await IsPublishedAsync(track.Id)); + } + + [Fact] + public async Task Single_UnprocessedTrackThatIsPublished_CanStillBeUnpublished() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio(), isPublished: true); + await SeedAsync(owner, track); + + var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct); + + Assert.False(result.IsError); + Assert.False(await IsPublishedAsync(track.Id)); + } + + [Fact] + public async Task Single_NotTheOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var track = TestEntities.Track(owner, isPublished: false); + await SeedAsync(owner, stranger, track); + + var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(stranger.Id, track.Id, true), Ct); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.False(await IsPublishedAsync(track.Id)); + } + + [Fact] + public async Task Single_MissingOrRemovingTrack_ReturnsNotFound() + { + var owner = TestEntities.User(); + var removing = TestEntities.Track(owner, lifeCycleStatus: LifeCycleStatus.Removing, isPublished: false); + await SeedAsync(owner, removing); + + var missing = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, Guid.NewGuid(), true), Ct); + var gone = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, removing.Id, true), Ct); + + Assert.Equal(ErrorType.NotFound, missing.FirstError.Type); + Assert.Equal(ErrorType.NotFound, gone.FirstError.Type); + } + + [Fact] + public async Task Batch_PublishesProcessedTracksAndSkipsTheRest() + { + var owner = TestEntities.User(); + var ready = TestEntities.Track(owner, "Ready", isPublished: false); + var processing = TestEntities.Track(owner, "Processing", audio: TestEntities.PendingAudio(), isPublished: false); + var missing = Guid.NewGuid(); + await SeedAsync(owner, ready, processing); + + var result = await CreateBatchHandler().Handle( + new SetTracksPublishedCommand(owner.Id, [ready.Id, processing.Id, missing, ready.Id], true), Ct); + + Assert.False(result.IsError); + Assert.Equal([ready.Id], result.Value.UpdatedTrackIds); + Assert.Equal([processing.Id, missing], result.Value.SkippedTrackIds); + Assert.True(await IsPublishedAsync(ready.Id)); + Assert.False(await IsPublishedAsync(processing.Id)); + } + + [Fact] + public async Task Batch_Unpublish_AppliesToEveryOwnedTrack() + { + var owner = TestEntities.User(); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second", audio: TestEntities.PendingAudio()); + await SeedAsync(owner, first, second); + + var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [first.Id, second.Id], false), Ct); + + Assert.Equal(2, result.Value.UpdatedTrackIds.Count); + Assert.Empty(result.Value.SkippedTrackIds); + Assert.False(await IsPublishedAsync(first.Id)); + Assert.False(await IsPublishedAsync(second.Id)); + } + + [Fact] + public async Task Batch_TrackOfAnotherUser_ReturnsForbiddenAndChangesNothing() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var mine = TestEntities.Track(owner, "Mine", isPublished: false); + var theirs = TestEntities.Track(stranger, "Theirs", isPublished: false); + await SeedAsync(owner, stranger, mine, theirs); + + var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [mine.Id, theirs.Id], true), Ct); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.False(await IsPublishedAsync(mine.Id)); + Assert.False(await IsPublishedAsync(theirs.Id)); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/TrackCreationWithoutPictureTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/TrackCreationWithoutPictureTests.cs new file mode 100644 index 0000000..649139d --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/TrackCreationWithoutPictureTests.cs @@ -0,0 +1,152 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Pictures; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class TrackCreationWithoutPictureTests : HandlerTestBase + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly IEventBus _eventBus = Substitute.For(); + private readonly IStorageService _storageService = Substitute.For(); + + private CreateTrackCommandHandler CreateHandler() + { + _storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("audio/mpeg", 1024)); + + return new CreateTrackCommandHandler( + Database, + _eventBus, + new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Track()); + } + + [Fact] + public async Task CreateTrack_WithoutPicture_UsesPresetsAndSkipsPictureProcessing() + { + var user = TestEntities.User(); + var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"); + await SeedAsync(user, audio); + var routes = TestConfigurations.Track().Routes; + + var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "No cover", null, audio.Id, [Genre.Pop]), Ct); + + Assert.False(result.IsError); + Assert.False(result.Value.IsPublished); + var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct); + Assert.Null(stored.Pictures.OriginalName); + Assert.Equal(routes.PresetSmallPicture, stored.Pictures.SmallName); + Assert.Equal(routes.PresetMediumPicture, stored.Pictures.MediumName); + Assert.Equal(routes.PresetLargePicture, stored.Pictures.LargeName); + Assert.Equal(ProcessingStatus.Completed, stored.Pictures.ProcessingStatus); + Assert.False(stored.IsPublished); + + await _eventBus.Received(1).PublishAsync( + Arg.Is(message => + message.PictureIntentId == null + && message.PictureSourceKey == null + && message.PictureDestinationKey == null + && message.AudioIntentId == audio.Id), + Arg.Any()); + } + + [Fact] + public async Task CreateTrack_WithPicture_StaysUnpublishedAndKeepsPictureFlow() + { + var user = TestEntities.User(); + var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"); + var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"); + await SeedAsync(user, picture, audio); + + var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "Cover", picture.Id, audio.Id, [Genre.Pop]), Ct); + + Assert.False(result.IsError); + Assert.False(result.Value.IsPublished); + var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct); + Assert.Equal("cover.webp", stored.Pictures.OriginalName); + Assert.Equal(ProcessingStatus.Pending, stored.Pictures.ProcessingStatus); + await _eventBus.Received(1).PublishAsync( + Arg.Is(message => message.PictureIntentId == picture.Id && message.PictureSourceKey != null), + Arg.Any()); + } + + private GetCoverQueryHandler CreateCoverHandler() => new( + Database, + TestConfigurations.Storage("covers-bucket"), + TestConfigurations.Track(), + TestConfigurations.Album(), + TestConfigurations.PlayList()); + + [Fact] + public async Task Cover_TrackWithoutPictureInAnAlbum_UsesTheAlbumCover() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, pictures: new TrackPictures + { + SmallName = "PresetSmallPicture.webp", + MediumName = "PresetMediumPicture.webp", + LargeName = "PresetLargePicture.webp", + ProcessingStatus = ProcessingStatus.Completed + }); + var album = TestEntities.Album(owner.Id, pictures: new EntityPictures + { + OriginalName = "album-original.webp", + SmallName = "album-small.webp", + MediumName = "album-medium.webp", + LargeName = "album-large.webp" + }); + await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }); + + var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct); + + Assert.False(result.IsError); + Assert.EndsWith("album-small.webp", result.Value.Key); + } + + [Fact] + public async Task Cover_TrackWithoutPictureAndAlbum_FallsBackToThePreset() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, pictures: new TrackPictures + { + SmallName = "PresetSmallPicture.webp", + MediumName = "PresetMediumPicture.webp", + LargeName = "PresetLargePicture.webp", + ProcessingStatus = ProcessingStatus.Completed + }); + await SeedAsync(owner, track); + + var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct); + + Assert.False(result.IsError); + Assert.EndsWith("PresetSmallPicture.webp", result.Value.Key); + } + + [Fact] + public async Task Cover_TrackWithOwnPicture_IgnoresTheAlbumCover() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }); + + var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct); + + Assert.EndsWith("small.webp", result.Value.Key); + Assert.DoesNotContain("album", result.Value.Key, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/UnpublishedTrackVisibilityTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/UnpublishedTrackVisibilityTests.cs new file mode 100644 index 0000000..beb744c --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/UnpublishedTrackVisibilityTests.cs @@ -0,0 +1,259 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums; +using Musify.Application.Contracts; +using Musify.Application.Genres; +using Musify.Application.Likes; +using Musify.Application.Mixes; +using Musify.Application.PlayLists; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class UnpublishedTrackVisibilityTests : HandlerTestBase + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private async Task<(User Owner, User Other, Track Live, Track Hidden)> SeedTracksAsync() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + var live = TestEntities.Track(owner, "Live"); + var hidden = TestEntities.Track(owner, "Hidden", isPublished: false, tags: [Genre.Jazz]); + await SeedAsync(owner, other, live, hidden); + return (owner, other, live, hidden); + } + + [Fact] + public async Task Search_HidesUnpublishedTracksEvenFromTheOwner() + { + var (_, _, live, _) = await SeedTracksAsync(); + + var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20), Ct); + + Assert.Equal([live.Id], result.Items.Select(t => t.Id)); + } + + [Fact] + public async Task Search_ByGenre_HidesUnpublishedTracks() + { + await SeedTracksAsync(); + + var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20, Genre.Jazz), Ct); + + Assert.Empty(result.Items); + } + + [Fact] + public async Task UserTracks_OwnerSeesUnpublishedAndCanFilterThem() + { + var (owner, _, live, hidden) = await SeedTracksAsync(); + var handler = new GetTracksByUserIdQueryHandler(Database); + + var all = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id), Ct); + var onlyHidden = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: false), Ct); + var onlyLive = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: true), Ct); + + Assert.Equal(2, all.Value.TotalItemCount); + Assert.Equal([hidden.Id], onlyHidden.Value.Items.Select(t => t.Id)); + Assert.False(onlyHidden.Value.Items[0].IsPublished == true); + Assert.Equal([live.Id], onlyLive.Value.Items.Select(t => t.Id)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UserTracks_OtherViewersAndAnonymousOnlySeePublished(bool signedIn) + { + var (owner, other, live, _) = await SeedTracksAsync(); + + var result = await new GetTracksByUserIdQueryHandler(Database) + .Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null, IsPublished: false), Ct); + + Assert.Empty(result.Value.Items); + + var all = await new GetTracksByUserIdQueryHandler(Database) + .Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null), Ct); + Assert.Equal([live.Id], all.Value.Items.Select(t => t.Id)); + } + + [Fact] + public async Task TrackById_OnlyTheOwnerCanReadAnUnpublishedTrack() + { + var (owner, other, _, hidden) = await SeedTracksAsync(); + var handler = new GetTrackByIdQueryHandler(Database); + + var asOwner = await handler.Handle(new GetTrackByIdQuery(hidden.Id, owner.Id), Ct); + var asOther = await handler.Handle(new GetTrackByIdQuery(hidden.Id, other.Id), Ct); + var anonymous = await handler.Handle(new GetTrackByIdQuery(hidden.Id), Ct); + + Assert.False(asOwner.IsError); + Assert.False(asOwner.Value.IsPublished); + Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type); + Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type); + } + + [Fact] + public async Task StartListening_OnlyTheOwnerCanPlayAnUnpublishedTrack() + { + var (owner, other, _, hidden) = await SeedTracksAsync(); + var ticketService = Substitute.For(); + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket", 60)); + var playback = TestConfigurations.Playback(allowAnonymousListening: true); + var issuer = new TrackStreamIssuer(ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback); + var handler = new StartListeningCommandHandler(Database, issuer, playback, NoOpLogger()); + + var asOwner = await handler.Handle(new StartListeningCommand(hidden.Id, owner.Id), Ct); + var asOther = await handler.Handle(new StartListeningCommand(hidden.Id, other.Id), Ct); + var anonymous = await handler.Handle(new StartListeningCommand(hidden.Id, null), Ct); + + Assert.False(asOwner.IsError); + Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type); + Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type); + Assert.Single(await Database.ListeningHistories.ToListAsync(Ct)); + } + + [Fact] + public async Task Genres_CountOnlyPublishedTracks() + { + await SeedTracksAsync(); + + var genres = await new GetGenresQueryHandler(Database).Handle(new GetGenresQuery(), Ct); + + var pop = Assert.Single(genres); + Assert.Equal(Genre.Pop, pop.Genre); + Assert.Equal(1, pop.TrackCount); + } + + [Fact] + public async Task ListeningHistory_AndLastListened_SkipUnpublishedTracks() + { + var (owner, _, live, hidden) = await SeedTracksAsync(); + await SeedAsync( + TestEntities.ListeningHistory(owner.Id, live.Id, DateTime.UtcNow.AddMinutes(-10)), + TestEntities.ListeningHistory(owner.Id, hidden.Id, DateTime.UtcNow)); + + var history = await new GetListeningHistoryQueryHandler(Database).Handle(new GetListeningHistoryQuery(owner.Id), Ct); + var last = await new GetLastTrackListenedByUserIdQueryHandler(Database).Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), Ct); + + Assert.Equal([live.Id], history.Select(t => t.Id)); + Assert.Equal(live.Id, last?.Id); + } + + [Fact] + public async Task Likes_UnpublishedTracksCannotBeLikedAndDisappearFromTheLikedList() + { + var (owner, other, live, hidden) = await SeedTracksAsync(); + var toggle = new ToggleTrackLikeCommandHandler(Database, NoOpLogger()); + + var likeHidden = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, hidden.Id), Ct); + var likeLive = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, live.Id), Ct); + await SeedAsync(new TrackLike { UserId = owner.Id, TrackId = hidden.Id }); + + var likedByOwner = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(owner.Id, 1, 20), Ct); + var likedByOther = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(other.Id, 1, 20), Ct); + + Assert.Equal(ErrorType.NotFound, likeHidden.FirstError.Type); + Assert.False(likeLive.IsError); + Assert.Empty(likedByOwner.Items); + Assert.Equal([live.Id], likedByOther.Items.Select(t => t.Id)); + } + + [Fact] + public async Task Mixes_OnlyUseAndShowPublishedTracks() + { + var (owner, _, live, hidden) = await SeedTracksAsync(); + + await new GenerateMixesForUserCommandHandler(Database, TestConfigurations.Mix(), NoOpLogger()) + .Handle(new GenerateMixesForUserCommand(owner.Id), Ct); + + var generated = await Database.MixItems.Select(item => item.TrackId).Distinct().ToListAsync(Ct); + Assert.Equal([live.Id], generated); + + var mix = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 5); + await SeedAsync(mix, TestEntities.MixItem(mix.Id, live.Id, 0), TestEntities.MixItem(mix.Id, hidden.Id, 1)); + + var byId = await new GetMixByIdQueryHandler(Database).Handle(new GetMixByIdQuery(owner.Id, mix.Id), Ct); + var list = await new GetMixesByUserIdQueryHandler(Database).Handle(new GetMixesByUserIdQuery(owner.Id), Ct); + + Assert.Equal([live.Id], byId.Value.Items.Select(i => i.TrackId)); + Assert.Equal(1, byId.Value.ItemCount); + var listed = list.Single(m => m.Id == mix.Id); + Assert.Equal(1, listed.ItemCount); + Assert.Equal([live.Id], listed.Items.Select(i => i.TrackId)); + } + + [Fact] + public async Task Albums_HideUnpublishedTracksEverywhere() + { + var (owner, other, live, hidden) = await SeedTracksAsync(); + var album = TestEntities.Album(owner.Id); + await SeedAsync( + album, + new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 }, + TestEntities.ListeningHistory(other.Id, hidden.Id)); + + var tracks = await new GetAlbumTracksQueryHandler(Database).Handle(new GetAlbumTracksQuery(album.Id, 1, 20), Ct); + var detail = await new GetAlbumByIdQueryHandler(Database).Handle(new GetAlbumByIdQuery(album.Id), Ct); + var byUser = await new GetAlbumsByUserIdQueryHandler(Database).Handle(new GetAlbumsByUserIdQuery(owner.Id, 1, 20), Ct); + var all = await new GetAlbumsQueryHandler(Database).Handle(new GetAlbumsQuery(null, 1, 20), Ct); + var recent = await new GetRecentlyListenedAlbumsQueryHandler(Database).Handle(new GetRecentlyListenedAlbumsQuery(other.Id, 10), Ct); + var ids = await new GetAlbumTrackIdsQueryHandler(Database).Handle(new GetAlbumTrackIdsQuery(owner.Id, album.Id), Ct); + + Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id)); + Assert.Equal(1, detail.Value.TrackCount); + Assert.Equal([live.Id], detail.Value.CoverTrackIds); + Assert.Equal(1, byUser.Value.Items.Single().TrackCount); + Assert.Equal(1, all.Items.Single().TrackCount); + Assert.Empty(recent); + Assert.Equal([hidden.Id, live.Id], ids.Value.TrackIds); + } + + [Fact] + public async Task PlayLists_HideUnpublishedTracksAndRejectAddingThem() + { + var (owner, other, live, hidden) = await SeedTracksAsync(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Public); + await SeedAsync( + playList, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = hidden.Id, Position = 0 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = live.Id, Position = 1 }); + + var tracks = await new GetPlayListTracksQueryHandler(Database).Handle(new GetPlayListTracksQuery(playList.Id, 1, 20, owner.Id), Ct); + var detail = await new GetPlayListByIdQueryHandler(Database).Handle(new GetPlayListByIdQuery(playList.Id, other.Id), Ct); + var add = await new AddTracksToPlayListCommandHandler(Database, NoOpLogger()) + .Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [hidden.Id]), Ct); + + Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id)); + Assert.Equal(1, detail.Value.TrackCount); + Assert.Equal(live.DurationSeconds, detail.Value.DurationSeconds); + Assert.Equal([live.Id], detail.Value.CoverTrackIds); + Assert.Equal(ErrorType.NotFound, add.FirstError.Type); + } + + [Fact] + public async Task AddAlbumToPlayList_SkipsUnpublishedTracks() + { + var (owner, _, live, hidden) = await SeedTracksAsync(); + var album = TestEntities.Album(owner.Id); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync( + album, playList, + new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 }); + + var result = await new AddAlbumToPlayListCommandHandler(Database, NoOpLogger()) + .Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), Ct); + + Assert.Equal(1, result.Value.AddedCount); + Assert.Equal([live.Id], await Database.PlayListHasTracks.Select(link => link.TrackId).ToListAsync(Ct)); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs index c829886..a97abbe 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs @@ -113,6 +113,32 @@ namespace Musify.Infrastructure.Tests.MassTransit slip.Itinerary.Select(activity => activity.Name)); } + [Fact] + public async Task BuildTrack_ProcessedWithoutOwnPicture_NeverRemovesThePresets() + { + var track = await SeedTrackAsync( + new TrackPictures + { + SmallName = "PresetSmallPicture.webp", + MediumName = "PresetMediumPicture.webp", + LargeName = "PresetLargePicture.webp", + ProcessingStatus = ProcessingStatus.Completed + }, + new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.RemoveTrackProcessedAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + [Fact] public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() { diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs index 4c2bd7e..cf9b186 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs @@ -33,6 +33,24 @@ namespace Musify.Infrastructure.Tests.MassTransit Assert.False(await harness.Published.Any(Ct)); } + [Fact] + public async Task Track_WithoutPicture_CompletesOnceTheAudioIsProcessed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish( + new CreateTrackResourcesEvent(id, null, Guid.NewGuid(), "bucket", null, null, "audio-source", "audio", "audio-folder", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + [Fact] public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() { diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs index de09a5b..6d3bf78 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs @@ -88,6 +88,43 @@ namespace Musify.Infrastructure.Tests.MassTransit AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); } + [Fact] + public void CreateTrack_WithPicture_CopiesBothFilesAndConsumesBothIntents() + { + var audioIntentId = Guid.NewGuid(); + var message = new CreateTrackResourcesEvent( + SubjectId, IntentId, audioIntentId, "bucket", "picture-source", "picture-final", "audio-source", "audio-final", "audio-folder", Sizes); + + var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + Assert.Equal( + [ActivityNames.CopyPictureToFinal, ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents], + slip.Itinerary.Select(activity => activity.Name)); + Assert.Equal("picture-final", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); + var consume = new Dictionary(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Equal([IntentId, audioIntentId], (IEnumerable)consume["IntentIds"]); + } + + [Fact] + public void CreateTrack_WithoutPicture_SkipsThePictureCopyAndOnlyConsumesTheAudioIntent() + { + var audioIntentId = Guid.NewGuid(); + var message = new CreateTrackResourcesEvent( + SubjectId, null, audioIntentId, "bucket", null, null, "audio-source", "audio-final", "audio-folder", Sizes); + + var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build(); + + Assert.Equal( + [ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents], + slip.Itinerary.Select(activity => activity.Name)); + Assert.False(slip.Variables.ContainsKey(RoutingSlipVariableNames.Workflow.PictureKey)); + Assert.Equal("audio-final", slip.Variables[RoutingSlipVariableNames.Workflow.AudioKey]); + var consume = new Dictionary(slip.Itinerary[1].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Equal([audioIntentId], (IEnumerable)consume["IntentIds"]); + var publish = new Dictionary(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Null(publish["FinalPictureKey"]); + } + [Fact] public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() {