diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs deleted file mode 100644 index ee2902e..0000000 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ /dev/null @@ -1,58 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.Entities; - -namespace Musify.Application.Albums; - -public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) - : ICommand>; - -public class AddTrackToAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> -{ - public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) - { - var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (album.IsError) - return album.Errors; - - var track = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); - if (track.IsError) - return track.Errors; - - var alreadyAdded = await database.AlbumHasTracks - .AsNoTracking() - .AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken); - if (alreadyAdded) - return AlreadyInAlbum; - - // Album track numbers are user-visible, so they start at 1. - var nextTrackNumber = (await database.AlbumHasTracks - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) - .MaxAsync(albumTrack => (int?)albumTrack.TrackNumber, cancellationToken) ?? 0) + 1; - - await database.AlbumHasTracks.AddAsync(new AlbumHasTrack - { - AlbumId = request.AlbumId, - TrackId = request.TrackId, - TrackNumber = nextTrackNumber - }, cancellationToken); - - var saved = await database.TrySaveChangesAsync(AlreadyInAlbum, cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}", - request.TrackId, request.AlbumId, nextTrackNumber); - - return Result.Success; - } - - private static Error AlreadyInAlbum => AppErrors.Conflict("Album.TrackAlreadyAdded", "Track is already in the album."); -} diff --git a/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs new file mode 100644 index 0000000..39a0679 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs @@ -0,0 +1,77 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums; + +/// Appends your own tracks to an album in the given order, skipping the ones it already has. +public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList TrackIds) + : ICommand>; + +public class AddTracksToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> +{ + public async ValueTask> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { track.Id, track.OwnerUserId }) + .ToListAsync(cancellationToken); + + var missing = requestedIds.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 alreadyInAlbum = await database.AlbumHasTracks + .AsNoTracking() + .Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Album track numbers are user-visible, so they start at 1. + var nextTrackNumber = (await database.AlbumHasTracks + .Where(link => link.AlbumId == request.AlbumId) + .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; + + foreach (var trackId in toAdd) + { + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = trackId, + TrackNumber = nextTrackNumber++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId); + + return Result.Success; + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index eae6f11..5a97f89 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; namespace Musify.Application.Albums.Responses; @@ -9,7 +7,7 @@ public record AlbumApplicationResponse( string Title, string? Description, int? ReleaseYear, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, int TrackCount, string? SmallImageKeyName, string? MediumImageKeyName, diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index 9bed6b1..5ec518a 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -16,8 +16,6 @@ public interface IDatabase public DbSet Tracks { get; } - public DbSet UserHasTracks { get; } - public DbSet PlayListHasTracks { get; } public DbSet Mixes { get; } diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 4d54270..396db60 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -15,7 +15,7 @@ public class GetGenresQueryHandler(IDatabase database) { var rows = await database.TrackTags .AsNoTracking() - .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)) + .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) .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/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 587aa3a..38e16ff 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -18,16 +18,16 @@ public class GenerateMixesForUserCommandHandler( ILogger logger) : ICommandHandler> { - private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList TrackIds); + private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { var userId = request.UserId; - var libraryTrackIds = await database.UserHasTracks + var libraryTrackIds = await database.Tracks .AsNoTracking() - .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(userTrack => userTrack.TrackId) + .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) .ToListAsync(cancellationToken); if (libraryTrackIds.Count == 0) @@ -51,19 +51,13 @@ public class GenerateMixesForUserCommandHandler( var discovery = Take(Shuffle(unheardTrackIds)); if (discovery.Count > 0) { - drafts.Add(new MixDraft( - "Descubrimiento", - "Canciones de tu biblioteca que todavía no has escuchado.", - discovery)); + drafts.Add(new MixDraft(MixKind.Discovery, discovery)); } var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); if (daily.Count > 0) { - drafts.Add(new MixDraft( - "Tu mezcla diaria", - "Lo que más escuchas, con alguna sorpresa de tu biblioteca.", - daily)); + drafts.Add(new MixDraft(MixKind.Daily, daily)); } if (drafts.Count == 0) @@ -108,12 +102,6 @@ public class GenerateMixesForUserCommandHandler( if (previousMixes.Count > 0) { - var previousIds = previousMixes.Select(mix => mix.Id).ToList(); - var previousItems = await database.MixItems - .Where(item => previousIds.Contains(item.MixId)) - .ToListAsync(cancellationToken); - - database.MixItems.RemoveRange(previousItems); database.Mixes.RemoveRange(previousMixes); } @@ -123,8 +111,7 @@ public class GenerateMixesForUserCommandHandler( var mix = new Mix { UserId = userId, - Title = draft.Title, - Subtitle = draft.Subtitle, + Kind = draft.Kind, Position = position }; diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index a831c12..e9e44f1 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -26,6 +26,6 @@ public class GetMixByIdQueryHandler(IDatabase database) var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); - return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items); + return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index bcb68b3..303c41c 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -42,7 +42,7 @@ public class GetMixesByUserIdQueryHandler(IDatabase database) .Select(item => coverResponses[item.TrackId]) .ToList(); - responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, covers)); + responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); } return responses; diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs index bb83d13..801988e 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,9 +1,10 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Application.Mixes.Responses; public record MixApplicationResponse( Guid Id, - string Title, - string? Subtitle, + MixKind Kind, int ItemCount, IReadOnlyList Items) { diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs deleted file mode 100644 index 7e9184a..0000000 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ /dev/null @@ -1,61 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Shared; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.PlayLists; - -public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) - : ICommand>; - -public class AddTrackToPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> -{ - public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) - { - var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (playList.IsError) - return playList.Errors; - - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (!trackExists) - return AppErrors.NotFound("Track", request.TrackId); - - var alreadyAdded = await database.PlayListHasTracks - .AsNoTracking() - .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); - if (alreadyAdded) - return AlreadyInPlayList; - - // Playlist positions are internal indexes, so they start at 0. - var nextPosition = (await database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId) - .MaxAsync(plt => (int?)plt.Position, cancellationToken) ?? -1) + 1; - - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack - { - PlayListId = request.PlayListId, - TrackId = request.TrackId, - Position = nextPosition - }, cancellationToken); - - var saved = await database.TrySaveChangesAsync(AlreadyInPlayList, cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}", - request.TrackId, request.PlayListId, nextPosition); - - return Result.Success; - } - - private static Error AlreadyInPlayList => AppErrors.Conflict("PlayList.TrackAlreadyAdded", "Track is already in the playlist."); -} diff --git a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs new file mode 100644 index 0000000..12d5bb4 --- /dev/null +++ b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs @@ -0,0 +1,73 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists; + +/// Appends tracks to a playlist in the given order, skipping the ones it already has. +public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList TrackIds) + : ICommand>; + +public class AddTracksToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> +{ + public async ValueTask> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var existingIds = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var alreadyInPlayList = await database.PlayListHasTracks + .AsNoTracking() + .Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Playlist positions are internal indexes, so they start at 0. + var nextPosition = (await database.PlayListHasTracks + .Where(link => link.PlayListId == request.PlayListId) + .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; + + foreach (var trackId in toAdd) + { + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = trackId, + Position = nextPosition++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId); + + return Result.Success; + } +} diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 8b9ddb4..48ea745 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,8 +1,6 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using System.Text.Json.Serialization; -using Musify.Application.Serialization; namespace Musify.Application.PlayLists.Responses; @@ -17,7 +15,7 @@ public record PlayListApplicationResponse( DateTime CreatedAt, DateTime UpdatedAt, IReadOnlyList CoverTrackIds, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, int TrackCount, double DurationSeconds) { diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs index 490cfb0..6786cf7 100644 --- a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -1,28 +1,20 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; namespace Musify.Application.Services; +/// Builds the stream URL and signed ticket for a processed track. It never touches the database. public sealed class TrackStreamIssuer( - IDatabase database, IStreamTicketService ticketService, TrackConfiguration trackConfiguration, StreamGatewayConfiguration streamGatewayConfiguration, PlaybackConfiguration playbackConfiguration) { - public async Task IssueAsync( - Guid trackId, - string audioFolderName, - long? userId, - CancellationToken cancellationToken) + public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) { var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); - var keyPrefix = $"{folderPath}/"; - - var maxBytes = AnonymousFragmentBytes(userId); - var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes); + var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId)); var manifestUrl = string.Join('/', streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), @@ -30,19 +22,6 @@ public sealed class TrackStreamIssuer( folderPath, streamGatewayConfiguration.AudioFileName); - Guid? listenId = null; - if (userId != null) - { - var newListeningHistory = new ListeningHistory - { - UserId = userId.Value, - TrackId = trackId, - }; - await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - listenId = newListeningHistory.Id; - } - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); } diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index ec28219..6c42b75 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -41,21 +41,6 @@ public class CreateTrackCommandHandler( } var distinctTags = request.Tags.Distinct().ToList(); - if (distinctTags.Count == 0) - { - logger.LogWarning("Track {Title} was submitted without any tags", request.Title); - return Error.Validation(description: "At least one tag is required."); - } - - var tagConflicts = GenreCompatibility.FindConflicts(distinctTags); - if (tagConflicts.Count > 0) - { - var conflict = tagConflicts.First(); - logger.LogWarning( - "Track {Title} was submitted with incompatible tags {First} and {Second}", - request.Title, conflict.First, conflict.Second); - return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."); - } if (request.PictureIntentId == request.AudioIntentId) return Error.Validation(description: "Picture and audio must use different upload intents."); @@ -105,11 +90,6 @@ public class CreateTrackCommandHandler( await database.Tracks.AddAsync(trackEntity, cancellationToken); - await database.UserHasTracks.AddAsync(new UserHasTrack - { - UserId = request.UserId, - TrackId = trackEntity.Id - }, cancellationToken); var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); await eventBus.PublishAsync( diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index 0cc95cc..9ced6ff 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -17,9 +17,6 @@ public class RecordListeningProgressCommandHandler(IDatabase database) { public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) { - if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0) - return Error.Validation(description: "playedSeconds must be a non-negative number."); - var listen = await database.ListeningHistories .Include(l => l.Track) .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 12a76de..da88141 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -14,7 +12,7 @@ public record TrackApplicationResponse( int ListensCount, DateTime CreatedAt, DateTime UpdatedAt, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + long OwnerUserId, IReadOnlyCollection Tags, bool IsExplicit) { diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/StartListening.cs similarity index 60% rename from backend/Core/Musify.Application/Tracks/GetTrackStream.cs rename to backend/Core/Musify.Application/Tracks/StartListening.cs index 6678931..d580698 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/StartListening.cs @@ -5,24 +5,25 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; -using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; +/// Starts a listen: records it for signed-in users and issues the stream ticket. +public record StartListeningCommand(Guid TrackId, long? UserId) + : ICommand>; -public record GetTrackStreamQuery(Guid TrackId, long? UserId) - : IQuery>; - -public class GetTrackStreamQueryHandler( +public class StartListeningCommandHandler( IDatabase database, TrackStreamIssuer streamIssuer, PlaybackConfiguration playbackConfiguration, - ILogger logger) - : IQueryHandler> + ILogger logger) + : ICommandHandler> { - public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(StartListeningCommand request, CancellationToken cancellationToken) { if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); @@ -38,10 +39,17 @@ public class GetTrackStreamQueryHandler( if (!track.Audio.IsProcessed) return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); - var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); + Guid? listenId = null; + if (request.UserId is { } userId) + { + var listen = new ListeningHistory { UserId = userId, TrackId = track.Id }; + await database.ListeningHistories.AddAsync(listen, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + listenId = listen.Id; + } logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); - return response; + return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); } } diff --git a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index d0d8840..2ab2b7b 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,11 +1,9 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; using Musify.Domain.Entities; namespace Musify.Application.Users.Responses; public record UserApplicationResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs index b1d9520..1c3bf41 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs @@ -1,10 +1,8 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; namespace Musify.Application.Users.Responses; public record UserProfileResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs index 23eedf3..34651c6 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -1,10 +1,8 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; namespace Musify.Application.Users.Responses; public record UserSummaryResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index 9487931..52df127 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Musify.Domain.ValueObjects; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; @@ -13,11 +14,7 @@ public class Mix : IAuditable public required long UserId { get; set; } - [MaxLength(100)] - public required string Title { get; set; } - - [MaxLength(256)] - public string? Subtitle { get; set; } + public MixKind Kind { get; set; } public int Position { get; set; } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index 8474fed..04da190 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -36,8 +36,6 @@ public class Track : IAuditable, IHasLifeCycle, IOwnedEntity public TrackAudio Audio { get; set; } = new(); - public ICollection UserTracks { get; set; } = new List(); - public ICollection PlayListTracks { get; set; } = new List(); public ICollection AlbumTracks { get; set; } = new List(); diff --git a/backend/Core/Musify.Domain/Entities/User.cs b/backend/Core/Musify.Domain/Entities/User.cs index e12912c..7616a1e 100644 --- a/backend/Core/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -30,8 +30,6 @@ public class User : IAuditable public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection UserTracks { get; set; } = new List(); - public ICollection PlayLists { get; set; } = new List(); public ICollection ListeningHistories { get; set; } = new List(); diff --git a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs deleted file mode 100644 index d2998b9..0000000 --- a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities; - -[Table("UserHasTrack")] -public class UserHasTrack -{ - public Guid Id { get; set; } = Guid.NewGuid(); - - public required long UserId { get; set; } - - public required Guid TrackId { get; set; } - - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; - - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; -} diff --git a/backend/Core/Musify.Domain/ValueObjects/MixKind.cs b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs new file mode 100644 index 0000000..2e3fb69 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs @@ -0,0 +1,7 @@ +namespace Musify.Domain.ValueObjects; + +public enum MixKind +{ + Discovery = 0, + Daily = 1 +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 70e5e9f..0814c5c 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -181,8 +181,6 @@ public class Database(DatabaseConfiguration configuration) public DbSet AlbumHasTracks => Set(); - public DbSet UserHasTracks => Set(); - public DbSet PlayListHasTracks => Set(); public DbSet Mixes => Set(); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.Designer.cs new file mode 100644 index 0000000..91dcf21 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.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("20260924101123_DropUserHasTrack")] + partial class DropUserHasTrack + { + /// + 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", "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("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("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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("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/20260924101123_DropUserHasTrack.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs new file mode 100644 index 0000000..cd4fd04 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101123_DropUserHasTrack.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class DropUserHasTrack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserHasTrack"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserHasTrack", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserHasTrack", x => x.Id); + table.ForeignKey( + name: "FK_UserHasTrack_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserHasTrack_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_TrackId", + table: "UserHasTrack", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_UserId", + table: "UserHasTrack", + column: "UserId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs new file mode 100644 index 0000000..94412dd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.Designer.cs @@ -0,0 +1,1084 @@ +// +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("20260924101237_MixKind")] + partial class MixKind + { + /// + 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", "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("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("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/20260924101237_MixKind.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs new file mode 100644 index 0000000..9427cc7 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924101237_MixKind.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class MixKind : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Kind", + table: "Mixes", + type: "integer", + nullable: false, + defaultValue: 0); + + // Mixes used to store their Spanish title; the daily mix is kind 1, everything else is discovery. + migrationBuilder.Sql("UPDATE \"Mixes\" SET \"Kind\" = 1 WHERE \"Title\" = 'Tu mezcla diaria';"); + + migrationBuilder.DropColumn( + name: "Subtitle", + table: "Mixes"); + + migrationBuilder.DropColumn( + name: "Title", + table: "Mixes"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Kind", + table: "Mixes"); + + migrationBuilder.AddColumn( + name: "Subtitle", + table: "Mixes", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "Mixes", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: ""); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 1ad303c..84961db 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -304,17 +304,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Position") + b.Property("Kind") .HasColumnType("integer"); - b.Property("Subtitle") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property("Position") + .HasColumnType("integer"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -626,27 +620,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("UserFollows"); }); - modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("TrackId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("bigint"); - - b.HasKey("Id"); - - b.HasIndex("TrackId"); - - b.HasIndex("UserId"); - - b.ToTable("UserHasTrack"); - }); - modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => { b.Property("CorrelationId") @@ -1070,25 +1043,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Follower"); }); - modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => - { - b.HasOne("Musify.Domain.Entities.Track", "Track") - .WithMany("UserTracks") - .HasForeignKey("TrackId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.User", "User") - .WithMany("UserTracks") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Track"); - - b.Navigation("User"); - }); - modelBuilder.Entity("Musify.Domain.Entities.Album", b => { b.Navigation("AlbumTracks"); @@ -1113,8 +1067,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("PlayListTracks"); b.Navigation("Tags"); - - b.Navigation("UserTracks"); }); modelBuilder.Entity("Musify.Domain.Entities.User", b => @@ -1122,8 +1074,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("ListeningHistories"); b.Navigation("PlayLists"); - - b.Navigation("UserTracks"); }); #pragma warning restore 612, 618 } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs new file mode 100644 index 0000000..6c0d0e5 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs @@ -0,0 +1,3 @@ +namespace Musify.Api.DataTransferObjects; + +public record AddTracksRequest(IReadOnlyList TrackIds); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs deleted file mode 100644 index d000ed6..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record AddPlayListTrackRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index e4ad0e9..481e0d4 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -1,5 +1,6 @@ using Mediator; using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Extensions; using Musify.Api.Filters; @@ -102,11 +103,13 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum) - .WithName("AddTrackToAlbum") - .WithSummary("Add One Of Your Uploaded Tracks To An Album.") + group.MapPost("/{id}/tracks", AddAlbumTracks) + .WithName("AddAlbumTracks") + .WithSummary("Add Your Uploaded Tracks To An Album, Skipping The Ones It Already Has.") + .AddEndpointFilter>() .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) @@ -242,15 +245,15 @@ public static class AlbumEndpoints return result.ToHttpResult(); } - private static async Task AddTrackToAlbum( + private static async Task AddAlbumTracks( IMediator mediator, CurrentUser currentUser, - Guid albumId, - Guid trackId, + Guid id, + AddTracksRequest request, CancellationToken cancellationToken) { var result = await mediator.Send( - new AddTrackToAlbumCommand(currentUser.RequiredId, albumId, trackId), + new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 70ca6ce..c25e71d 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -1,5 +1,6 @@ using Mediator; using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; using Musify.Api.Filters; @@ -10,7 +11,6 @@ using Musify.Application.PlayLists; using Musify.Application.PlayLists.Responses; using Musify.Api.Models; using Musify.Application.Shared; -using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; namespace Musify.Api.Endpoints; @@ -87,11 +87,13 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{playlistId}/tracks", AddPlayListTrack) - .WithName("AddPlayListTrack") - .WithSummary("Add One Of Your Tracks To A PlayList.") + group.MapPost("/{id}/tracks", AddPlayListTracks) + .WithName("AddPlayListTracks") + .WithSummary("Add Tracks To A PlayList, Skipping The Ones It Already Has.") + .AddEndpointFilter>() .RequireAuthorization() - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) @@ -203,21 +205,18 @@ public static class PlayListEndpoints return result.ToHttpResult(); } - private static async Task AddPlayListTrack( + private static async Task AddPlayListTracks( IMediator mediator, CurrentUser currentUser, - Guid playlistId, - AddPlayListTrackRequest request, + Guid id, + AddTracksRequest request, CancellationToken cancellationToken) { - var addResult = await mediator.Send( - new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId), + var result = await mediator.Send( + new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds), cancellationToken); - if (addResult.IsError) - return addResult.ToHttpResult(); - var trackResult = await mediator.Send(new GetTrackByIdQuery(request.TrackId), cancellationToken); - return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); + return result.ToHttpResult(); } private static async Task RemoveTrackFromPlayList( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 8b6e765..1a1fd88 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -9,7 +9,6 @@ using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; -using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints; @@ -47,6 +46,7 @@ public static class TrackEndpoints .Produces(StatusCodes.Status404NotFound); group.MapPut("/listens/{listenId}/progress", RecordListeningProgress) + .AddEndpointFilter>() .WithName("RecordListeningProgress") .WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.") .RequireAuthorization() @@ -99,24 +99,10 @@ public static class TrackEndpoints IMediator mediator, CancellationToken cancellationToken, string? name, - string? genre, + GenreParameter? genre, [AsParameters] PageQuery page) { - Genre? parsedGenre = null; - if (!string.IsNullOrWhiteSpace(genre)) - { - if (!Enum.TryParse(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value)) - { - return Results.ValidationProblem(new Dictionary - { - [nameof(genre)] = [$"'{genre}' is not a valid genre."] - }); - } - - parsedGenre = value; - } - - var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); return Results.Ok(result); } @@ -146,7 +132,7 @@ public static class TrackEndpoints Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken); + var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Models/GenreParameter.cs b/backend/Hosts/Musify.Api/Models/GenreParameter.cs new file mode 100644 index 0000000..8539c14 --- /dev/null +++ b/backend/Hosts/Musify.Api/Models/GenreParameter.cs @@ -0,0 +1,19 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Api.Models; + +/// Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400. +public readonly record struct GenreParameter(Genre Value) +{ + public static bool TryParse(string? value, out GenreParameter result) + { + if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + { + result = new GenreParameter(genre); + return true; + } + + result = default; + return false; + } +} diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index d70dfbc..a7b1cbd 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Musify.Api.Authentication; -using Musify.Application.Serialization; namespace Musify.Api.OpenApi; @@ -41,10 +40,16 @@ public sealed class OpenApiOptionsSetup(IOptions op options.AddSchemaTransformer((schema, context, _) => { - if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter) + // Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53). + // Request DTOs only carry byte sizes, which may be sent as numbers or strings. + if (context.JsonPropertyInfo is { } property + && (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?))) { - schema.Type = JsonSchemaType.String; - schema.Format = null; + var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true; + schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String; + if (property.PropertyType == typeof(long?)) + schema.Type |= JsonSchemaType.Null; + schema.Format = isRequest ? "int64" : null; } return Task.CompletedTask; diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 28d26c7..c1f1257 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -1,13 +1,18 @@ using System.Text.Json.Serialization; using Musify.Api.Endpoints; using Musify.Api.Extensions; +using Musify.Api.Serialization; using Musify.Infrastructure.Observability; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(); builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerOptions.Converters.Add(new LongAsStringConverter()); + options.SerializerOptions.Converters.Add(new NullableLongAsStringConverter()); +}); const string devCorsPolicy = "dev-cors"; diff --git a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs b/backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs similarity index 96% rename from backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs rename to backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs index a0d7642..587708c 100644 --- a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs +++ b/backend/Hosts/Musify.Api/Serialization/LongAsStringConverter.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace Musify.Application.Serialization; +namespace Musify.Api.Serialization; public sealed class LongAsStringConverter : JsonConverter { diff --git a/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs new file mode 100644 index 0000000..d3466e5 --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects; + +namespace Musify.Api.Validators; + +public sealed class AddTracksRequestValidator : AbstractValidator +{ + public const int MaxTracksPerRequest = 500; + + public AddTracksRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs new file mode 100644 index 0000000..518be4b --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; + +namespace Musify.Api.Validators.Tracks; + +public sealed class RecordListeningProgressRequestValidator : AbstractValidator +{ + public RecordListeningProgressRequestValidator() + { + RuleFor(x => x.PlayedSeconds) + .Must(seconds => double.IsFinite(seconds) && seconds >= 0) + .WithMessage("'Played Seconds' must be a non-negative number."); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index b8790cb..28def3b 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -40,7 +40,6 @@ public sealed class GenreEndpointsTests(ApiTestFixture fixture) tag.TrackId = track.Id; database.Users.Add(owner); database.Tracks.Add(track); - database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); await database.SaveChangesAsync(TestContext.Current.CancellationToken); return title; } diff --git a/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs new file mode 100644 index 0000000..9643e18 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs @@ -0,0 +1,48 @@ +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Validators.Tracks; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Api.Tests.Validators; + +public sealed class TrackRequestValidatorTests +{ + [Fact] + public void CreateTrack_ValidRequest_Passes() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]); + + Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void CreateTrack_NoTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []); + + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Fact] + public void CreateTrack_IncompatibleTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]); + + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void RecordListeningProgress_InvalidSeconds_Fails(double seconds) + { + Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid); + } + + [Fact] + public void RecordListeningProgress_ZeroSeconds_Passes() + { + Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs similarity index 69% rename from backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs index 784c48e..1619700 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs @@ -7,9 +7,9 @@ using Xunit; namespace Musify.Application.Tests.Albums; -public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase +public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase { - private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); [Fact] public async Task Handle_FirstTrack_AddsItAtNumberOne() @@ -19,7 +19,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var track = TestEntities.Track(owner); await SeedAsync(owner, album, track); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); @@ -33,7 +33,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var track = TestEntities.Track(owner); await SeedAsync(owner, track); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -47,7 +47,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, album, track); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } @@ -59,7 +59,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -73,13 +73,13 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var foreignTrack = TestEntities.Track(otherOwner); await SeedAsync(owner, otherOwner, album, foreignTrack); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] - public async Task Handle_AlreadyInAlbum_ReturnsConflict() + public async Task Handle_AlreadyInAlbum_IsSkipped() { var owner = TestEntities.User(); var album = TestEntities.Album(owner.Id); @@ -87,9 +87,10 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; await SeedAsync(owner, album, track, link); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.False(result.IsError); + Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -102,7 +103,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 }; await SeedAsync(owner, album, firstTrack, secondTrack, existingLink); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs index c2e4393..cb76ee5 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Genres; using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Xunit; @@ -26,10 +25,7 @@ public sealed class GetGenresQueryHandlerTests : HandlerTestBase var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); await SeedAsync( - owner, rockA, rockB, jazz, - new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id }, - new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id }, - new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + owner, rockA, rockB, jazz); var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); @@ -38,31 +34,4 @@ public sealed class GetGenresQueryHandlerTests : HandlerTestBase result.Select(r => (r.Genre, r.TrackCount))); } - [Fact] - public async Task Handle_TrackInSeveralLibraries_IsCountedOnce() - { - var owner = TestEntities.User(); - var other = TestEntities.User(id: 2, name: "other-user"); - var track = TestEntities.Track(owner, tags: [Genre.Pop]); - await SeedAsync( - owner, other, track, - new UserHasTrack { UserId = owner.Id, TrackId = track.Id }, - new UserHasTrack { UserId = other.Id, TrackId = track.Id }); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Equal(1, Assert.Single(result).TrackCount); - } - - [Fact] - public async Task Handle_TrackNotInAnyLibrary_IsIgnored() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, tags: [Genre.Metal]); - await SeedAsync(owner, track); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Empty(result); - } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index ad45bd3..e7eb3c6 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes; @@ -30,7 +30,6 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase var track = TestEntities.Track(user); await SeedAsync( user, track, - new UserHasTrack { UserId = user.Id, TrackId = track.Id }, TestEntities.ListeningHistory(user.Id, track.Id)); var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); @@ -38,7 +37,7 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase Assert.False(result.IsError); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(mixes); - Assert.Equal("Tu mezcla diaria", mixes[0].Title); + Assert.Equal(MixKind.Daily, mixes[0].Kind); } [Fact] @@ -48,25 +47,23 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase var listenedTrack = TestEntities.Track(user, "Listened Track"); var unheardTrack = TestEntities.Track(user, "Unheard Track"); - var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); + var staleMix = TestEntities.Mix(user.Id, MixKind.Daily); var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); await SeedAsync( user, listenedTrack, unheardTrack, staleMix, staleItem, - new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id }, - new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id }, TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); - Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria"); + Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily); Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento"); + var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery); var discoveryItems = await Database.MixItems .Where(item => item.MixId == discoveryMix.Id) .ToListAsync(TestContext.Current.CancellationToken); @@ -80,12 +77,11 @@ public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase var skipped = TestEntities.Track(user, "Only skipped"); await SeedAsync( user, skipped, - new UserHasTrack { UserId = user.Id, TrackId = skipped.Id }, TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index e7a68b8..25274c3 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -1,6 +1,7 @@ using ErrorOr; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes; @@ -15,7 +16,7 @@ public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase var owner = TestEntities.User(); var firstTrack = TestEntities.Track(owner, "First"); var secondTrack = TestEntities.Track(owner, "Second"); - var mix = TestEntities.Mix(owner.Id, "Discovery"); + var mix = TestEntities.Mix(owner.Id, MixKind.Daily); var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); @@ -23,7 +24,7 @@ public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal("Discovery", result.Value.Title); + Assert.Equal(MixKind.Daily, result.Value.Kind); Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs index 9a20c90..f46344f 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Mixes; @@ -13,13 +14,13 @@ public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase { var owner = TestEntities.User(1, "owner"); var other = TestEntities.User(2, "other"); - var second = TestEntities.Mix(owner.Id, "Second", position: 1); - var first = TestEntities.Mix(owner.Id, "First", position: 0); - var theirs = TestEntities.Mix(other.Id, "Theirs"); + var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1); + var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0); + var theirs = TestEntities.Mix(other.Id); await SeedAsync(owner, other, second, first, theirs); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Equal(["First", "Second"], result.Select(mix => mix.Title)); + Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind)); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs similarity index 58% rename from backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs index fd3626b..f37c54e 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs @@ -7,9 +7,9 @@ using Xunit; namespace Musify.Application.Tests.PlayLists; -public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase +public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase { - private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); [Fact] public async Task Handle_FirstTrack_AddsItAtPositionZero() @@ -19,7 +19,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); @@ -37,7 +37,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase owner, playList, existingTrack, newTrack, new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 }); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken); Assert.False(result.IsError); var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken); @@ -47,7 +47,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase [Fact] public async Task Handle_PlayListMissing_ReturnsNotFound() { - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -60,7 +60,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, stranger, playList); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } @@ -72,22 +72,23 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase var playList = TestEntities.PlayList(owner.Id); await SeedAsync(owner, playList); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } [Fact] - public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict() + public async Task Handle_TrackAlreadyInPlayList_IsSkipped() { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + Assert.False(result.IsError); + Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -103,4 +104,26 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase Assert.Equal("Test.Duplicate", result.FirstError.Code); } + + [Fact] + public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var existing = TestEntities.Track(owner, "Existing"); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + await SeedAsync( + owner, playList, existing, first, second, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 }); + + var result = await CreateHandler().Handle( + new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]), + TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([0, 1, 2], links.Select(link => link.Position)); + } } diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index 5fbecf3..21b4d47 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; @@ -8,90 +7,56 @@ using Xunit; namespace Musify.Application.Tests.Services; -public sealed class TrackStreamIssuerTests : HandlerTestBase +public sealed class TrackStreamIssuerTests { private readonly IStreamTicketService ticketService = Substitute.For(); + public TrackStreamIssuerTests() => + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"), playback ?? TestConfigurations.Playback()); [Fact] - public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() + public void Issue_BuildsManifestUrlAndCarriesTheListenId() { - ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - var owner = TestEntities.User(7); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var listenId = Guid.NewGuid(); - var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); + var response = CreateIssuer().Issue("folder-42", userId: 7, listenId); Assert.Equal("signed-token", response.Ticket); Assert.Equal(120, response.ExpiresInSeconds); - Assert.Equal( - "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", - response.ManifestUrl); - - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(7, history.UserId); - Assert.Equal(track.Id, history.TrackId); + Assert.Equal(listenId, response.ListenId); + Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl); } [Fact] - public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() + public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService() { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - - await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + CreateIssuer().Issue("folder-42", userId: 1, listenId: null); ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); } [Fact] - public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows() + public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - - var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) - .IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); - - Assert.Equal("token", response.Ticket); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + + CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null); ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); } [Fact] - public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + + CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null); ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 37bab0e..081ced1 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -143,8 +143,6 @@ public sealed class TestDatabase : DbContext, IDatabase public DbSet Tracks => Set(); - public DbSet UserHasTracks => Set(); - public DbSet PlayListHasTracks => Set(); public DbSet Mixes => Set(); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index b058d4d..3aeaca5 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -154,14 +154,12 @@ public static class TestEntities public static Mix Mix( long userId, - string title = "Test Mix", - string? subtitle = null, + MixKind kind = MixKind.Discovery, int position = 0) => new() { UserId = userId, - Title = title, - Subtitle = subtitle, + Kind = kind, Position = position }; diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index 26a32e6..ecb346d 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -89,34 +89,6 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase Assert.Equal(isExplicit, stored.IsExplicit); } - [Fact] - public async Task Handle_NoTags_ReturnsValidationError() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); - - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } - - [Fact] - public async Task Handle_IncompatibleTags_ReturnsValidationError() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); - - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } - [Fact] public async Task Handle_UserMissing_ReturnsNotFound() { diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 2db7b57..4217572 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; -using Musify.Domain.Entities; using Xunit; namespace Musify.Application.Tests.Tracks; @@ -17,9 +16,7 @@ public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase var mine = TestEntities.Track(owner, "Mine"); var theirs = TestEntities.Track(other, "Theirs"); await SeedAsync( - owner, other, mine, theirs, - new UserHasTrack { UserId = owner.Id, TrackId = mine.Id }, - new UserHasTrack { UserId = other.Id, TrackId = theirs.Id }); + owner, other, mine, theirs); var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index 88a12c1..eee38d9 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -16,9 +16,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase var trackA = TestEntities.Track(owner, "Alpha"); var trackB = TestEntities.Track(owner, "Beta"); await SeedAsync( - owner, trackA, trackB, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id }); + owner, trackA, trackB); var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.Equal(2, result.TotalItemCount); @@ -32,9 +30,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase var match = TestEntities.Track(owner, "Bohemian Rhapsody"); var other = TestEntities.Track(owner, "Imagine"); await SeedAsync( - owner, match, other, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); + owner, match, other); var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); var item = Assert.Single(result.Items); @@ -48,9 +44,7 @@ public sealed class GetTracksQueryHandlerTests : HandlerTestBase var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); await SeedAsync( - owner, rock, jazz, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + owner, rock, jazz); var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); var item = Assert.Single(result.Items); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs index de49205..0db7d21 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -110,17 +110,4 @@ public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } - [Theory] - [InlineData(-1)] - [InlineData(double.NaN)] - [InlineData(double.PositiveInfinity)] - public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds) - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs similarity index 75% rename from backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs rename to backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs index e88e4e9..87bc5eb 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs @@ -10,22 +10,21 @@ using Xunit; namespace Musify.Application.Tests.Tracks; -public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase +public sealed class StartListeningCommandHandlerTests : HandlerTestBase { private readonly IStreamTicketService ticketService = Substitute.For(); - private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null) + private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null) { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); var issuer = new TrackStreamIssuer( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test"), playback ?? TestConfigurations.Playback()); - return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); + return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); } [Fact] @@ -35,7 +34,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase var track = TestEntities.Track(owner); await SeedAsync(owner, track); - var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal("ticket-token", result.Value.Ticket); @@ -53,7 +52,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase [Fact] public async Task Handle_TrackMissing_ReturnsNotFound() { - var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -65,7 +64,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); await SeedAsync(owner, track); - var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -79,7 +78,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); - var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } @@ -93,7 +92,7 @@ public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); - var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal("ticket-token", result.Value.Ticket); diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 1fb9389..16a265e 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -142,6 +142,23 @@ export interface paths { patch?: never; trace?: never; }; + "/albums/{id}/tracks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add Your Uploaded Tracks To An Album, Skipping The Ones It Already Has. */ + post: operations["AddAlbumTracks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums/{albumId}/tracks/{trackId}": { parameters: { query?: never; @@ -151,8 +168,7 @@ export interface paths { }; get?: never; put?: never; - /** Add One Of Your Uploaded Tracks To An Album. */ - post: operations["AddTrackToAlbum"]; + post?: never; /** Remove A Track From An Album. */ delete: operations["RemoveTrackFromAlbum"]; options?: never; @@ -323,8 +339,7 @@ export interface paths { /** Get Paginated Tracks Of A PlayList. */ get: operations["GetPlayListTracks"]; put?: never; - /** Add One Of Your Tracks To A PlayList. */ - post: operations["AddPlayListTrack"]; + post?: never; delete?: never; options?: never; head?: never; @@ -400,6 +415,23 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists/{id}/tracks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add Tracks To A PlayList, Skipping The Ones It Already Has. */ + post: operations["AddPlayListTracks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists/{playlistId}/tracks/{trackId}": { parameters: { query?: never; @@ -729,9 +761,8 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { - AddPlayListTrackRequest: { - /** Format: uuid */ - trackId: string; + AddTracksRequest: { + trackIds: string[]; }; AlbumApplicationResponse: { /** Format: uuid */ @@ -810,8 +841,7 @@ export interface components { MixApplicationResponse: { /** Format: uuid */ id: string; - title: string; - subtitle: null | string; + kind: components["schemas"]["MixKind"]; /** Format: int32 */ itemCount: number | string; items: components["schemas"]["MixItemApplicationResponse"][]; @@ -826,6 +856,8 @@ export interface components { /** Format: int32 */ listensCount: number | string; }; + /** @enum {unknown} */ + MixKind: "Discovery" | "Daily"; PaginatedResponseOfAlbumApplicationResponse: { items: components["schemas"]["AlbumApplicationResponse"][]; /** Format: int32 */ @@ -1157,7 +1189,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: unknown; }; cookie?: never; }; @@ -1415,17 +1447,20 @@ export interface operations { }; }; }; - AddTrackToAlbum: { + AddAlbumTracks: { parameters: { query?: never; header?: never; path: { - albumId: string; - trackId: string; + id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AddTracksRequest"]; + }; + }; responses: { /** @description No Content */ 204: { @@ -1434,6 +1469,15 @@ export interface operations { }; content?: never; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; /** @description Unauthorized */ 401: { headers: { @@ -1735,7 +1779,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: unknown; }; cookie?: never; }; @@ -1784,60 +1828,6 @@ export interface operations { }; }; }; - AddPlayListTrack: { - parameters: { - query?: never; - header?: never; - path: { - playlistId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddPlayListTrackRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TrackApplicationResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetPlayListCover: { parameters: { query?: { @@ -2058,6 +2048,67 @@ export interface operations { }; }; }; + AddPlayListTracks: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddTracksRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; RemoveTrackFromPlayList: { parameters: { query?: never; @@ -2329,7 +2380,7 @@ export interface operations { }; header?: never; path: { - userId: number | string; + userId: unknown; }; cookie?: never; }; @@ -2477,7 +2528,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2506,7 +2557,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2535,7 +2586,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2571,7 +2622,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2600,7 +2651,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2632,7 +2683,7 @@ export interface operations { }; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2671,7 +2722,7 @@ export interface operations { }; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2700,7 +2751,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2729,7 +2780,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; @@ -2772,7 +2823,7 @@ export interface operations { query?: never; header?: never; path: { - id: number | string; + id: unknown; }; cookie?: never; }; diff --git a/web-player/src/lib/data/mixes.ts b/web-player/src/lib/data/mixes.ts index aa8d2ee..a2b5be5 100644 --- a/web-player/src/lib/data/mixes.ts +++ b/web-player/src/lib/data/mixes.ts @@ -4,6 +4,21 @@ import type { ApiTrackLike } from '$lib/player/player.svelte'; export type Mix = components['schemas']['MixApplicationResponse']; export type MixItem = components['schemas']['MixItemApplicationResponse']; +const MIX_TEXTS: Record = { + Discovery: { + title: 'Descubrimiento', + subtitle: 'Canciones de tu biblioteca que todavía no has escuchado.' + }, + Daily: { + title: 'Tu mezcla diaria', + subtitle: 'Lo que más escuchas, con alguna sorpresa de tu biblioteca.' + } +}; + +export function mixText(mix: Pick): { title: string; subtitle: string } { + return MIX_TEXTS[mix.kind]; +} + export function mixItemKey(item: MixItem): string { return `mf:${item.trackId}`; } diff --git a/web-player/src/lib/server/playlistActions.ts b/web-player/src/lib/server/playlistActions.ts index 318b4ef..2f93141 100644 --- a/web-player/src/lib/server/playlistActions.ts +++ b/web-player/src/lib/server/playlistActions.ts @@ -15,15 +15,12 @@ export async function addTrackAction({ request, params, locals, fetch }: Request } const api = createApiClient({ fetch, accessToken }); - const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', { - params: { path: { playlistId } }, - body: { trackId } + const { error: err } = await api.POST('/playlists/{id}/tracks', { + params: { path: { id: playlistId } }, + body: { trackIds: [trackId] } }); if (err) { - if (response?.status === 409) { - return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); - } return fail(502, { message: 'No se pudo añadir la canción.', trackId }); } @@ -51,14 +48,18 @@ export async function addAlbumToPlaylistAction({ request, params, locals, fetch return fail(502, { message: 'No se pudo cargar el álbum.', albumId }); } - let added = 0; - for (const track of tracks.items) { - const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { - params: { path: { playlistId } }, - body: { trackId: String(track.id) } - }); - if (!err) added++; + const trackIds = tracks.items.map((track) => String(track.id)); + if (trackIds.length === 0) { + return { addedAlbum: true, albumId, playlistId, added: 0 }; } - return { addedAlbum: true, albumId, playlistId, added }; + const { error: addErr } = await api.POST('/playlists/{id}/tracks', { + params: { path: { id: playlistId } }, + body: { trackIds } + }); + if (addErr) { + return fail(502, { message: 'No se pudo añadir el álbum.', albumId }); + } + + return { addedAlbum: true, albumId, playlistId, added: trackIds.length }; } diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index f77c3dc..e3d2b68 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -54,8 +54,9 @@ export const actions: Actions = { if (!trackId) return fail(400, { message: 'Falta la canción.' }); const api = createApiClient({ fetch, accessToken }); - const result = await api.POST('/albums/{albumId}/tracks/{trackId}', { - params: { path: { albumId: params.id, trackId } } + const result = await api.POST('/albums/{id}/tracks', { + params: { path: { id: params.id } }, + body: { trackIds: [trackId] } }); const failure = unwrapOrFail(result, 'No se pudo añadir la canción.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 6f1c7a2..c08cdd6 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -18,11 +18,12 @@ import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import ListPlus from '@lucide/svelte/icons/list-plus'; import { createTrackMenu } from '$lib/state/menus.svelte'; - import { mixItemTrack } from '$lib/data/mixes'; + import { mixItemTrack, mixText } from '$lib/data/mixes'; let { data, form } = $props(); const mix = $derived(data.mix); + const text = $derived(mixText(mix)); const items = $derived(mix.items); const tracks = $derived(items.map(mixItemTrack)); const queue = $derived(tracks.map(toQueueItem)); @@ -49,8 +50,8 @@ - {mix.title} - + {text.title} + @@ -58,15 +59,15 @@ {#snippet cover()} item.trackId)} size="hero" - alt={mix.title} + alt={text.title} class="shrink-0" /> {/snippet} diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index 906c6e6..498129a 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -14,7 +14,7 @@ } from '$lib/player/player.svelte'; import { pressable } from '$lib/actions/pressable'; import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed'; - import { mixItemTrack } from '$lib/data/mixes'; + import { mixItemTrack, mixText } from '$lib/data/mixes'; import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/utils/format'; import Page from '$lib/components/ui/layout/Page.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte'; @@ -279,8 +279,8 @@ {#each mixes as mix, i (mix.id)} item.trackId)} onPlay={(event) => playMix(mix, event)} index={i}