diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs index a8c783a..633dbb3 100644 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -3,6 +3,7 @@ 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; @@ -21,48 +22,30 @@ public class AddTrackToAlbumCommandHandler( .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album == null) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } + return AppErrors.NotFound("Album", request.AlbumId); if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Album", request.AlbumId); var track = await database.Tracks .AsNoTracking() .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); if (track == null) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } + return AppErrors.NotFound("Track", request.TrackId); if (track.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Track", request.TrackId); var alreadyAdded = await database.AlbumHasTracks .AsNoTracking() .AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken); if (alreadyAdded) - { - logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId); - return Error.Conflict(description: "Track is already in the album."); - } + return AlreadyInAlbum; - var albumTracksQuery = database.AlbumHasTracks - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); - - var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken); - var nextTrackNumber = hasTracks - ? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1 - : 1; + // 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 { @@ -71,11 +54,15 @@ public class AddTrackToAlbumCommandHandler( TrackNumber = nextTrackNumber }, cancellationToken); - await database.SaveChangesAsync(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 new Success(); + 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/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 4e124a1..39722ca 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -57,13 +57,7 @@ public class CreateAlbumCommandHandler( NormalizedTitle = title.ToUpperInvariant(), Description = request.Description, ReleaseYear = request.ReleaseYear, - Pictures = new AlbumPictures - { - OriginalName = intent.ObjectName, - SmallName = intent.ObjectName, - MediumName = intent.ObjectName, - LargeName = intent.ObjectName - } + Pictures = AlbumPictures.Pending(intent.ObjectName) }; await database.Albums.AddAsync(album, cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 11b1bf8..27787e9 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -3,6 +3,9 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -11,39 +14,27 @@ public record DeleteAlbumCommand(long UserId, Guid AlbumId) public class DeleteAlbumCommandHandler( IDatabase database, + IEventBus eventBus, ILogger logger) : ICommandHandler> { public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) { var album = await database.Albums - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + .SingleOrDefaultAsync(a => a.Id == request.AlbumId && a.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (album == null) - { - logger.LogDebug("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } + return AppErrors.NotFound("Album", request.AlbumId); if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Album", request.AlbumId); - database.Albums.Remove(album); + album.LifeCycleStatus = LifeCycleStatus.Removing; - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId); - return Error.Failure(description: $"Failed to delete album {request.AlbumId}"); - } + await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); + logger.LogInformation("Marked album {AlbumId} of user {UserId} as removing", request.AlbumId, request.UserId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 03991cb..8a72541 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -16,7 +17,7 @@ public class GetAlbumByIdQueryHandler(IDatabase database) { var album = await database.Albums .AsNoTracking() - .Where(a => a.Id == request.Id) + .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active) .Select(a => new { Album = a, diff --git a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs index 333f206..e4c0ddf 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs @@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore; using MimeMapping; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -20,14 +22,15 @@ public class GetAlbumCoverQueryHandler( { public async ValueTask> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) { - var pictures = await database.Albums.AsNoTracking() - .Where(a => a.Id == request.AlbumId) - .Select(a => a.Pictures) + var owned = await database.Albums.AsNoTracking() + .Where(e => e.Id == request.AlbumId && e.LifeCycleStatus == LifeCycleStatus.Active) + .Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures }) .SingleOrDefaultAsync(cancellationToken); - if (pictures == null) - return Error.NotFound(); + if (owned?.Pictures == null) + return AppErrors.NotFound("AlbumCover", request.AlbumId); + var pictures = owned.Pictures; var routes = albumConfiguration.Routes; var (name, key) = request.Size.ToLowerInvariant() switch { @@ -36,8 +39,15 @@ public class GetAlbumCoverQueryHandler( _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) }; + // The resized files only exist once processing has finished; until then serve the original. if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return Error.NotFound(); + { + name = pictures.OriginalName; + key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); + } + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return AppErrors.NotFound("AlbumCover", request.AlbumId); var contentType = MimeUtility.GetMimeMapping(name); return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index 1b5ed0d..9138cc7 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -6,6 +6,7 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -22,7 +23,7 @@ public class GetAlbumTracksQueryHandler(IDatabase database) { var albumExists = await database.Albums .AsNoTracking() - .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + .AnyAsync(album => album.Id == request.AlbumId && album.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); if (!albumExists) return Error.NotFound(); @@ -30,7 +31,7 @@ public class GetAlbumTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(albumTrack => albumTrack.Track.Owner) .Include(albumTrack => albumTrack.Track.Tags) - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index b8d64e9..7bec513 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -17,7 +18,7 @@ public class GetAlbumsQueryHandler(IDatabase database) { var albumsQuery = database.Albums .AsNoTracking() - .AsQueryable(); + .Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); if (!string.IsNullOrEmpty(request.Title)) { diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index 9f8c829..f9f18ac 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -6,6 +6,7 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -25,7 +26,7 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database) var albumsQuery = database.Albums .AsNoTracking() - .Where(album => album.OwnerUserId == request.UserId); + .Where(album => album.OwnerUserId == request.UserId && album.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await albumsQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index 4eeb1e4..bcee2e1 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; namespace Musify.Application.Albums; @@ -38,7 +39,7 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) var albums = await database.Albums .AsNoTracking() - .Where(album => albumIds.Contains(album.Id)) + .Where(album => albumIds.Contains(album.Id) && album.LifeCycleStatus == LifeCycleStatus.Active) .Select(album => new { Album = album, diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index a626a88..b84c21c 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.Albums; @@ -20,16 +21,10 @@ public class RemoveTrackFromAlbumCommandHandler( .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album == null) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } + return AppErrors.NotFound("Album", request.AlbumId); if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("Album", request.AlbumId); var albumTracks = await database.AlbumHasTracks .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index d2984a5..3c23fea 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -45,7 +45,7 @@ public class UpdateAlbumCommandHandler( if (album.OwnerUserId != request.UserId) { logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); - return Error.Unauthorized(); + return AppErrors.Forbidden("Album", request.AlbumId); } var title = request.Title.Trim(); @@ -67,13 +67,7 @@ public class UpdateAlbumCommandHandler( return validation.Errors; pictureIntent = validation.Value; - album.Pictures = new AlbumPictures - { - OriginalName = pictureIntent.ObjectName, - SmallName = pictureIntent.ObjectName, - MediumName = pictureIntent.ObjectName, - LargeName = pictureIntent.ObjectName - }; + album.Pictures = AlbumPictures.Pending(pictureIntent.ObjectName); finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index ea3c605..de3b8e2 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -1,3 +1,4 @@ +using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Domain.Entities; @@ -38,4 +39,6 @@ public interface IDatabase public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public Task SaveChangesAsync(CancellationToken cancellationToken); + + public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); } diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index 4b419be..ea79bdb 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -2,7 +2,7 @@ namespace Musify.Application.Contracts; public interface IStorageService { - public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); diff --git a/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs new file mode 100644 index 0000000..ab5cfd5 --- /dev/null +++ b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs @@ -0,0 +1,5 @@ +namespace Musify.Application.Events; + +public record DeleteAlbumEvent( + Guid AlbumId, + long UserId); diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 82fbf97..f85cbba 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -16,7 +16,7 @@ public class GetGenresQueryHandler(IDatabase database) { var rows = await database.TrackTags .AsNoTracking() - .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) + .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId && userTrack.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/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index d36f67c..d0468ad 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -6,6 +6,7 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Likes; @@ -24,7 +25,7 @@ public class GetLikedTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(like => like.Track.Owner) .Include(like => like.Track.Tags) - .Where(like => like.UserId == request.UserId); + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await likesQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index d87d641..468a655 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Likes; @@ -45,7 +46,11 @@ public class ToggleTrackLikeCommandHandler( nowLiked = true; } - await database.SaveChangesAsync(cancellationToken); + // A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); + if (saved.IsError) + return true; logger.LogInformation("User {UserId} {Action} track {TrackId}", request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index d988bb0..c40290d 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -21,14 +21,28 @@ public class GetMixesByUserIdQueryHandler(IDatabase database) .OrderBy(mix => mix.Position) .ToListAsync(cancellationToken); + var orderedItemsByMix = mixes.ToDictionary( + mix => mix.Id, + mix => mix.Items.OrderBy(item => item.Position).ToList()); + + var coverItems = orderedItemsByMix.Values + .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) + .ToList(); + var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) + .GroupBy(response => response.TrackId) + .ToDictionary(group => group.Key, group => group.First()); + var responses = new List(mixes.Count); foreach (var mix in mixes) { - var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); - var coverItems = await MixItemMapper.ToResponsesAsync( - database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken); + var orderedItems = orderedItemsByMix[mix.Id]; + var covers = orderedItems + .Take(MixApplicationResponse.CoverItemCount) + .Where(item => coverResponses.ContainsKey(item.TrackId)) + .Select(item => coverResponses[item.TrackId]) + .ToList(); - responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems)); + responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, covers)); } return responses; diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index eba4dd3..e4928dc 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.Mixes; @@ -18,7 +19,7 @@ internal static class MixItemMapper var tracksById = await database.Tracks .AsNoTracking() .Include(track => track.Owner) - .Where(track => trackIds.Contains(track.Id)) + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(x => x.Track.Id, cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs index 1c1c641..2c3f28f 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.PlayLists; @@ -18,44 +19,30 @@ public class AddTrackToPlayListCommandHandler( public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) { var playList = await database.PlayLists + .AsNoTracking() .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } + return AppErrors.NotFound("PlayList", request.PlayListId); if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("PlayList", request.PlayListId); var trackExists = await database.Tracks .AsNoTracking() .AnyAsync(t => t.Id == request.TrackId, cancellationToken); if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } + 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) - { - logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId); - return Error.Conflict(description: "Track is already in the playlist."); - } + return AlreadyInPlayList; - var playListTracksQuery = database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId); - - var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken); - var nextPosition = hasTracks - ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 - : 0; + // 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 { @@ -64,11 +51,15 @@ public class AddTrackToPlayListCommandHandler( Position = nextPosition }, cancellationToken); - await database.SaveChangesAsync(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 new Success(); + 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/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index c2bb640..ba015de 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -53,7 +53,7 @@ public class CreatePlayListCommandHandler( var playList = new PlayList { UserId = request.UserId, - Name = request.Name, + Name = request.Name.Trim(), NormalizedName = request.Name.Trim().ToUpperInvariant(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Pictures = resolvedPictures.Pictures, @@ -97,13 +97,7 @@ public class CreatePlayListCommandHandler( if (validation.IsError) return validation.Errors; - return new ResolvedPlayListPictures(new PlayListPictures - { - OriginalName = validation.Value.ObjectName, - SmallName = validation.Value.ObjectName, - MediumName = validation.Value.ObjectName, - LargeName = validation.Value.ObjectName - }, validation.Value); + return new ResolvedPlayListPictures(PlayListPictures.Pending(validation.Value.ObjectName), validation.Value); } private async Task> PublishCreatePlayListEventAsync( diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 066649a..710a5f4 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Domain.ValueObjects; @@ -29,7 +30,7 @@ public class DeletePlayListCommandHandler( if (playList.UserId != request.UserId) { logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); - return Error.Unauthorized(); + return AppErrors.Forbidden("PlayList", request.PlayListId); } playList.LifeCycleStatus = LifeCycleStatus.Removing; diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 506918d..869f0e1 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -17,7 +17,7 @@ public class GetPlayListByIdQueryHandler(IDatabase database) { var entry = await database.PlayLists .AsNoTracking() - .Where(p => p.Id == request.Id) + .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active) .Select(p => new { PlayList = p, diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs index 3e739ae..7660459 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs @@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore; using MimeMapping; using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists; @@ -20,14 +22,15 @@ public class GetPlayListCoverQueryHandler( { public async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) { - var pictures = await database.PlayLists.AsNoTracking() - .Where(p => p.Id == request.PlayListId) - .Select(p => p.Pictures) + var owned = await database.PlayLists.AsNoTracking() + .Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active) + .Select(e => new { OwnerUserId = e.UserId, e.Pictures }) .SingleOrDefaultAsync(cancellationToken); - if (pictures == null) - return Error.NotFound(); + if (owned?.Pictures == null) + return AppErrors.NotFound("PlayListCover", request.PlayListId); + var pictures = owned.Pictures; var routes = playListConfiguration.Routes; var (name, key) = request.Size.ToLowerInvariant() switch { @@ -36,8 +39,15 @@ public class GetPlayListCoverQueryHandler( _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) }; + // The resized files only exist once processing has finished; until then serve the original. if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return Error.NotFound(); + { + name = pictures.OriginalName; + key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name); + } + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return AppErrors.NotFound("PlayListCover", request.PlayListId); var contentType = MimeUtility.GetMimeMapping(name); return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 6a299b8..f25cabc 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -26,6 +26,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database) .AsNoTracking() .AnyAsync( p => p.Id == request.PlayListId + && p.LifeCycleStatus == LifeCycleStatus.Active && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId), cancellationToken); if (!playListExists) @@ -35,7 +36,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(plt => plt.Track.Owner) .Include(plt => plt.Track.Tags) - .Where(plt => plt.PlayListId == request.PlayListId); + .Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 0716ce7..e292836 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -26,6 +26,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database) var playListsQuery = database.PlayLists .AsNoTracking() .Where(p => p.UserId == request.UserId + && p.LifeCycleStatus == LifeCycleStatus.Active && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId)); if (!string.IsNullOrEmpty(request.Name)) @@ -38,7 +39,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database) var pagedEntities = await playListsQuery .OrderBy(p => p.CreatedAt) - .Where(p => p.LifeCycleStatus == LifeCycleStatus.Active) + .ThenBy(p => p.Id) .Select(p => new { PlayList = p, diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index 6a9ac11..da372fe 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; namespace Musify.Application.PlayLists; @@ -17,32 +18,33 @@ public class RemoveTrackFromPlayListCommandHandler( public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) { var playList = await database.PlayLists + .AsNoTracking() .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } + return AppErrors.NotFound("PlayList", request.PlayListId); if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } + return AppErrors.Forbidden("PlayList", request.PlayListId); - var link = await database.PlayListHasTracks - .SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); + var playListTracks = await database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId) + .OrderBy(plt => plt.Position) + .ToListAsync(cancellationToken); + + var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); if (link == null) - { - logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId); - return Error.NotFound(description: "Track is not in the playlist."); - } + return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist."); database.PlayListHasTracks.Remove(link); + + var position = 0; + foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id)) + remaining.Position = position++; + await database.SaveChangesAsync(cancellationToken); logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); - return new Success(); + return Result.Success; } } diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index a791752..d497e12 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -44,18 +44,18 @@ public class UpdatePlayListCommandHandler( if (playListEntity.UserId != request.UserId) { logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); + return AppErrors.Forbidden("PlayList", request.PlayListId); } if (!string.IsNullOrWhiteSpace(request.NewName)) { - playListEntity.Name = request.NewName; + playListEntity.Name = request.NewName.Trim(); playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); } - if (!string.IsNullOrWhiteSpace(request.NewDescription)) + if (request.NewDescription != null) { - playListEntity.Description = request.NewDescription; + playListEntity.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); } if (request.NewVisibility.HasValue) @@ -75,13 +75,7 @@ public class UpdatePlayListCommandHandler( return validation.Errors; pictureIntent = validation.Value; - playListEntity.Pictures = new PlayListPictures - { - OriginalName = pictureIntent.ObjectName, - SmallName = pictureIntent.ObjectName, - MediumName = pictureIntent.ObjectName, - LargeName = pictureIntent.ObjectName - }; + playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName); finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } diff --git a/backend/Core/Musify.Application/Shared/AppErrors.cs b/backend/Core/Musify.Application/Shared/AppErrors.cs new file mode 100644 index 0000000..326d420 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/AppErrors.cs @@ -0,0 +1,15 @@ +using ErrorOr; + +namespace Musify.Application.Shared; + +public static class AppErrors +{ + public static Error NotFound(string entity, object id) => + Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); + + public static Error Forbidden(string entity, object id) => + Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}."); + + public static Error Conflict(string code, string description) => + Error.Conflict(code, description); +} diff --git a/backend/Core/Musify.Application/Shared/Pagination.cs b/backend/Core/Musify.Application/Shared/Pagination.cs new file mode 100644 index 0000000..b32e156 --- /dev/null +++ b/backend/Core/Musify.Application/Shared/Pagination.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Shared; + +public record PageRequest(int PageNumber = 1, int PageSize = 20) +{ + public const int MaxPageSize = 100; +} diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index a4b0982..e4d75bb 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Application.Events; using Musify.Domain.ValueObjects; @@ -31,7 +32,7 @@ public class DeleteTrackCommandHandler( if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); - return Error.Unauthorized(); + return AppErrors.Forbidden("Track", request.TrackId); } if (track.Audio.IsInProgress) diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 030fe53..4048ba3 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -1,29 +1,26 @@ -using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; -public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery>; +public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery; public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) - : IQueryHandler> + : IQueryHandler { - public async ValueTask> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + public async ValueTask Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) { - var track = await database.ListeningHistories + return await database.ListeningHistories .AsNoTracking() .Include(t => t.Track.Owner) .Include(t => t.Track.Tags) - .Where(t => t.UserId == query.UserId) + .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) .OrderByDescending(t => t.ListenedAt) + .ThenBy(t => t.Id) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) .FirstOrDefaultAsync(cancellationToken); - - return track == null - ? Error.NotFound("Track.NotFound", "No track found for the given user ID.") - : track; } } diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index d61825b..e73095f 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -2,6 +2,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -28,11 +29,12 @@ public class GetListeningHistoryQueryHandler( .AsNoTracking() .Include(t => t.Owner) .Include(t => t.Tags) - .Where(t => recentTrackIds.Contains(t.Id)) + .Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(t => t.Id, cancellationToken); return recentTrackIds + .Where(id => tracksById.ContainsKey(id)) .Select(id => tracksById[id]) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount)); } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 9c73316..7f4a1a1 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -17,7 +18,7 @@ public class GetTrackByIdQueryHandler(IDatabase database) var entity = await database.Tracks.AsNoTracking() .Include(t => t.Owner) .Include(t => t.Tags) - .Where(t => t.Id == request.TrackId) + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs index a9ceae8..2204afe 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs @@ -6,6 +6,7 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -26,7 +27,7 @@ public class GetTrackStreamQueryHandler( return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId) + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) .Select(t => new { t.Id, t.Audio }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 8d0c533..6fc1d44 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -20,7 +20,7 @@ public class GetTracksQueryHandler(IDatabase database) .AsNoTracking() .Include(ut => ut.Track.Owner) .Include(ut => ut.Track.Tags) - .AsQueryable(); + .Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active); if (!string.IsNullOrEmpty(request.Name)) { diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index edeacf2..e03ecc8 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -6,6 +6,7 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks; @@ -21,14 +22,11 @@ public class GetTracksByUserIdQueryHandler(IDatabase database) { public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { - var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; - var pageSize = request.PageSize < 1 ? 10 : request.PageSize; - var tracksQuery = database.UserHasTracks .AsNoTracking() .Include(ut => ut.Track.Owner) .Include(ut => ut.Track.Tags) - .Where(p => p.UserId == request.UserId); + .Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active); if (!string.IsNullOrEmpty(request.Name)) { @@ -41,7 +39,7 @@ public class GetTracksByUserIdQueryHandler(IDatabase database) var pagedEntities = await tracksQuery .OrderBy(t => t.Track.CreatedAt) .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedTracks = new StaticPagedList( pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index 767d8f3..345fc89 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -3,6 +3,7 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Application.Shared; using Musify.Domain.Entities; namespace Musify.Application.Users; @@ -40,7 +41,11 @@ public class FollowUserCommandHandler( FollowedId = request.FollowedId }, cancellationToken); - await database.SaveChangesAsync(cancellationToken); + // A concurrent double click can hit the unique index; already following is a successful no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken); + if (saved.IsError) + return Result.Success; logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index 56428d8..a54880a 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -39,6 +39,9 @@ public class GetListeningStatsQueryHandler(IDatabase database) var listenedDateSet = listenedDates.ToHashSet(); var streakDays = 0; var day = DateTime.UtcNow.Date; + if (!listenedDateSet.Contains(day)) + day = day.AddDays(-1); + while (listenedDateSet.Contains(day)) { streakDays++; diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs index 82e2626..c505f0a 100644 --- a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs @@ -7,12 +7,11 @@ public class AlbumPictures [Required] public required string OriginalName { get; set; } - [Required] - public required string SmallName { get; set; } + public string? SmallName { get; set; } - [Required] - public required string MediumName { get; set; } + public string? MediumName { get; set; } - [Required] - public required string LargeName { get; set; } + public string? LargeName { get; set; } + + public static AlbumPictures Pending(string originalName) => new() { OriginalName = originalName }; } diff --git a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs index df8dc3e..3617896 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs @@ -7,12 +7,11 @@ public class PlayListPictures [Required] public required string OriginalName { get; set; } - [Required] - public required string SmallName { get; set; } + public string? SmallName { get; set; } - [Required] - public required string MediumName { get; set; } + public string? MediumName { get; set; } - [Required] - public required string LargeName { get; set; } + public string? LargeName { get; set; } + + public static PlayListPictures Pending(string originalName) => new() { OriginalName = originalName }; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs new file mode 100644 index 0000000..37baf24 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/DeleteAlbumFromDbActivity.cs @@ -0,0 +1,35 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums; + +internal class DeleteAlbumFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity +{ + public const string ExecuteEndpointName = "delete-album-from-db"; + + public async Task Execute(ExecuteContext executeContext) + { + var album = await database.Albums + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); + + if (album == null) + { + logger.LogInformation( + "Album {AlbumId} not found while deleting from DB, skipping", + executeContext.Arguments.AlbumId); + return executeContext.Completed(); + } + + database.Albums.Remove(album); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted album {AlbumId} from DB", album.Id); + return executeContext.Completed(); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs new file mode 100644 index 0000000..edec538 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsRemovingActivity.cs @@ -0,0 +1,36 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Albums; + +internal class MarkAlbumAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity +{ + public const string ExecuteEndpointName = "mark-album-as-removing"; + + public async Task Execute(ExecuteContext executeContext) + { + var album = await database.Albums + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); + + if (album == null) + { + logger.LogInformation( + "Album {AlbumId} not found while marking as removing, skipping", + executeContext.Arguments.AlbumId); + return executeContext.Completed(); + } + + album.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked album {AlbumId} as removing", album.Id); + return executeContext.Completed(); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index ec0c953..b59c525 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -23,7 +23,8 @@ internal class DownloadFileFromBucketActivity( using var fileStream = await storageHandler.GetFileAsync( executeContext.Arguments.Bucket, executeContext.Arguments.Key, - executeContext.CancellationToken); + executeContext.CancellationToken) + ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); var destinationDirectory = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrWhiteSpace(destinationDirectory)) diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs new file mode 100644 index 0000000..ec2a2ca --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteAlbumFromDbArguments.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record DeleteAlbumFromDbArguments( + Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs new file mode 100644 index 0000000..65dc6d0 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsRemovingArguments.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record MarkAlbumAsRemovingArguments( + Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs new file mode 100644 index 0000000..cf9b4fc --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class DeleteAlbumConsumer( + IBus bus, + DeleteAlbumRoutingSlipBuilder routingSlipBuilder) + : IConsumer +{ + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = (await routingSlipBuilder + .BuildAsync( + consumeContext.Message.AlbumId, + consumeContext.Message.UserId, + consumeContext.CorrelationId, + consumeContext.CancellationToken)) + .Build(); + + await bus.Execute(routingSlip); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index 9759c1b..678b679 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -22,6 +22,7 @@ public static partial class MassTransitDependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -35,6 +36,7 @@ public static partial class MassTransitDependencyInjection options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); + options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); @@ -58,6 +60,8 @@ public static partial class MassTransitDependencyInjection options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); options.AddExecuteActivity(); options.AddActivity(); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs index 7297012..d14f075 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs @@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs; public record UpdateAlbumPictureLog( Guid AlbumId, string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); + string? PreviousSmallPictureKey, + string? PreviousMediumPictureKey, + string? PreviousLargePictureKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs index 621bd87..8ee81b5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs @@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs; public record UpdatePlayListPictureLog( Guid PlayListId, string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); + string? PreviousSmallPictureKey, + string? PreviousMediumPictureKey, + string? PreviousLargePictureKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 6d44957..76d0a02 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -9,6 +9,8 @@ internal static class ActivityNames internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; + internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving"; + internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb"; internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; @@ -55,4 +57,7 @@ internal static class ActivityNames internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; + internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture"; + internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture"; + internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture"; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs new file mode 100644 index 0000000..1618709 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteAlbumRoutingSlipBuilder.cs @@ -0,0 +1,93 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Albums; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class DeleteAlbumRoutingSlipBuilder( + IDatabase database, + AlbumConfiguration albumConfiguration, + ApplicationStorageConfiguration storageConfiguration) +{ + public async Task BuildAsync( + Guid albumId, + long userId, + Guid? correlationId, + CancellationToken cancellationToken) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + var album = await database.Albums + .AsNoTracking() + .SingleOrDefaultAsync(p => p.Id == albumId, cancellationToken); + + routingSlipBuilder.AddActivity( + ActivityNames.MarkAlbumAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsRemovingActivity.ExecuteEndpointName), + new MarkAlbumAsRemovingArguments(albumId)); + + if (album?.Pictures != null) + { + var bucket = storageConfiguration.Bucket; + var routes = albumConfiguration.Routes; + + if (album.Pictures.OriginalName is { } originalName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildOriginalPicturePath(userId, originalName))); + } + + if (album.Pictures.SmallName is { } smallName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumSmallPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildSmallPicturePath(smallName))); + } + + if (album.Pictures.MediumName is { } mediumName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumMediumPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildMediumPicturePath(mediumName))); + } + + if (album.Pictures.LargeName is { } largeName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveAlbumLargePicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildLargePicturePath(largeName))); + } + } + + routingSlipBuilder.AddActivity( + ActivityNames.DeleteAlbumFromDb, + EndpointHelper.BuildExecuteActivityUri(DeleteAlbumFromDbActivity.ExecuteEndpointName), + new DeleteAlbumFromDbArguments(albumId)); + + return routingSlipBuilder; + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 1e84194..23a7e94 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -1,3 +1,4 @@ +using ErrorOr; using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; @@ -6,6 +7,7 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; +using Npgsql; using AppIDatabase = Musify.Application.Contracts.IDatabase; namespace Musify.Infrastructure.Persistence; @@ -82,9 +84,9 @@ public class Database(DatabaseConfiguration configuration) .OwnsOne(album => album.Pictures, pictures => { pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); }); modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); @@ -110,6 +112,10 @@ public class Database(DatabaseConfiguration configuration) .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); + modelBuilder.Entity() .HasIndex(mix => new { mix.UserId, mix.Position }); @@ -205,6 +211,19 @@ public class Database(DatabaseConfiguration configuration) return new DatabaseTransaction(transaction); } + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + return onUniqueViolation; + } + } + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction { public Task CommitAsync(CancellationToken cancellationToken) => diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs new file mode 100644 index 0000000..c1b855b --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.Designer.cs @@ -0,0 +1,1174 @@ +// +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("20260924030938_PlayListTrackUnique")] + partial class PlayListTrackUnique + { + /// + 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("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + 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.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + 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.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") + .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.AlbumPictures", "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", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "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("Pictures"); + + b.Navigation("User"); + }); + + 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("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + 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.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"); + }); + + 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"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs new file mode 100644 index 0000000..14525bd --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260924030938_PlayListTrackUnique.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class PlayListTrackUnique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Remove duplicate rows (keeping the lowest position) before enforcing uniqueness. + migrationBuilder.Sql(""" + DELETE FROM "PlayListHasTrack" a + USING "PlayListHasTrack" b + WHERE a."PlayListId" = b."PlayListId" + AND a."TrackId" = b."TrackId" + AND (a."Position", a."Id") > (b."Position", b."Id"); + """); + + migrationBuilder.DropIndex( + name: "IX_PlayListHasTrack_PlayListId", + table: "PlayListHasTrack"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_PlayListId_TrackId", + table: "PlayListHasTrack", + columns: new[] { "PlayListId", "TrackId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PlayListHasTrack_PlayListId_TrackId", + table: "PlayListHasTrack"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_PlayListId", + table: "PlayListHasTrack", + column: "PlayListId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index bf0434b..c708d49 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -417,10 +417,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("PlayListId"); - b.HasIndex("TrackId"); + b.HasIndex("PlayListId", "TrackId") + .IsUnique(); + b.ToTable("PlayListHasTrack"); }); @@ -790,13 +791,11 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnType("uuid"); b1.Property("LargeName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("LargePictureName"); b1.Property("MediumName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("MediumPictureName"); @@ -808,7 +807,6 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnName("OriginalPictureName"); b1.Property("SmallName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("SmallPictureName"); diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 8be9c1a..69e9855 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -15,7 +15,7 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { var request = new GetObjectRequest { @@ -29,6 +29,11 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger>() .WithName("GetAlbums") .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") .Produces(); @@ -30,6 +34,7 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetAlbumsByUserId) + .AddEndpointFilter>() .WithName("GetAlbumsByUserId") .WithSummary("Get Paginated Albums For A User.") .Produces>() @@ -43,6 +48,7 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status401Unauthorized); group.MapGet("/{albumId}/tracks", GetAlbumTracks) + .AddEndpointFilter>() .WithName("GetAlbumTracks") .WithSummary("Get The Tracks Of An Album Ordered By Track Number.") .Produces>() @@ -82,6 +88,7 @@ public static class AlbumEndpoints .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapDelete("/{albumId}", DeleteAlbum) @@ -90,6 +97,7 @@ public static class AlbumEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum) @@ -98,6 +106,7 @@ public static class AlbumEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -107,6 +116,7 @@ public static class AlbumEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); return app; @@ -116,10 +126,9 @@ public static class AlbumEndpoints IMediator mediator, CancellationToken cancellationToken, string? title, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -136,10 +145,9 @@ public static class AlbumEndpoints IMediator mediator, long userId, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -149,6 +157,14 @@ public static class AlbumEndpoints CancellationToken cancellationToken, int limit = 12) { + if (limit is < 1 or > MaxRecentAlbumsLimit) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."] + }); + } + var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); return result.ToHttpResult(); } @@ -157,10 +173,9 @@ public static class AlbumEndpoints IMediator mediator, Guid albumId, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 50) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetAlbumTracksQuery(albumId, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -178,6 +193,9 @@ public static class AlbumEndpoints var location = result.Value; var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; return Results.Stream(stream, location.ContentType); } diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index cd2fc5f..4c74e53 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -3,8 +3,10 @@ using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Likes; using Musify.Api.Extensions; using Musify.Application.Likes; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Api.Filters; namespace Musify.Api.Endpoints; @@ -17,6 +19,7 @@ public static class LikeEndpoints .RequireAuthorization(); group.MapGet("/", GetLikedTracks) + .AddEndpointFilter>() .WithName("GetLikedTracks") .WithSummary("Get The Current User'S Liked Tracks.") .Produces>() @@ -36,10 +39,9 @@ public static class LikeEndpoints IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 2fd1d69..dd57a99 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -6,6 +6,7 @@ using Musify.Api.Filters; using Musify.Application.Contracts; 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; @@ -26,11 +27,13 @@ public static class PlayListEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetPlayListsByUserId) + .AddEndpointFilter>() .WithName("GetPlayListsByUserId") .WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.") .Produces>(); group.MapGet("/{playlistId}/tracks", GetPlayListTracks) + .AddEndpointFilter>() .WithName("GetPlayListTracks") .WithSummary("Get Paginated Tracks Of A PlayList.") .Produces>() @@ -60,6 +63,7 @@ public static class PlayListEndpoints .Produces() .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapDelete("/{playlistId}", DeletePlayList) @@ -68,6 +72,7 @@ public static class PlayListEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); group.MapPost("/upload-picture", RequestPlayListPictureUpload) @@ -86,6 +91,7 @@ public static class PlayListEndpoints .RequireAuthorization() .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -95,6 +101,7 @@ public static class PlayListEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); return app; @@ -116,10 +123,9 @@ public static class PlayListEndpoints long userId, CancellationToken cancellationToken, string? name, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, currentUser.Id), cancellationToken); + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -128,10 +134,9 @@ public static class PlayListEndpoints CurrentUser currentUser, Guid playlistId, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize, currentUser.Id), cancellationToken); + var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -149,6 +154,9 @@ public static class PlayListEndpoints var location = result.Value; var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; return Results.Stream(stream, location.ContentType); } diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index f14b9f7..dcbf00b 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -4,6 +4,7 @@ using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; using Musify.Application.Contracts; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; @@ -19,6 +20,7 @@ public static class TrackEndpoints .WithTags("Tracks"); group.MapGet("/", GetTracks) + .AddEndpointFilter>() .WithName("GetTracks") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") .Produces() @@ -52,6 +54,7 @@ public static class TrackEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/users/{userId}", GetTracksByUserId) + .AddEndpointFilter>() .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User.") .Produces>(); @@ -73,6 +76,7 @@ public static class TrackEndpoints .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -95,8 +99,7 @@ public static class TrackEndpoints CancellationToken cancellationToken, string? name, string? genre, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { Genre? parsedGenre = null; if (!string.IsNullOrWhiteSpace(genre)) @@ -112,7 +115,7 @@ public static class TrackEndpoints parsedGenre = value; } - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken); return result.ToHttpResult(); } @@ -139,6 +142,9 @@ public static class TrackEndpoints var location = result.Value; var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; return Results.Stream(stream, location.ContentType); } @@ -171,10 +177,9 @@ public static class TrackEndpoints long userId, CancellationToken cancellationToken, string? name, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 3c0029a..af38b56 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,11 +1,13 @@ using Mediator; using Musify.Api.Authentication; using Musify.Api.Extensions; +using Musify.Api.Models; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Users; using Musify.Application.Users.Responses; +using Musify.Api.Filters; namespace Musify.Api.Endpoints; @@ -17,6 +19,7 @@ public static class UserEndpoints .WithTags("Users"); group.MapGet("/", GetUsers) + .AddEndpointFilter>() .WithName("GetUsers") .WithSummary("Get Paginated Users.") .Produces>(); @@ -45,7 +48,7 @@ public static class UserEndpoints .WithName("GetLastTrackListenedByUserId") .WithSummary("Get The Last Track A User Listened To.") .Produces() - .Produces(StatusCodes.Status404NotFound); + .Produces(StatusCodes.Status204NoContent); group.MapGet("/{id}/profile", GetUserProfile) .WithName("GetUserProfile") @@ -54,6 +57,7 @@ public static class UserEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/{id}/followers", GetFollowers) + .AddEndpointFilter>() .WithName("GetUserFollowers") .WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.") .Produces>() @@ -61,6 +65,7 @@ public static class UserEndpoints .Produces(StatusCodes.Status404NotFound); group.MapGet("/{id}/following", GetFollowing) + .AddEndpointFilter>() .WithName("GetUserFollowing") .WithSummary("Get The Users A User Follows.") .Produces>() @@ -97,10 +102,9 @@ public static class UserEndpoints CurrentUser currentUser, CancellationToken cancellationToken, string? usernameSearch, - int pageNumber = 1, - int pageSize = 10) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken); + var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -138,8 +142,8 @@ public static class UserEndpoints long id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); - return result.ToHttpResult(); + var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); + return track == null ? Results.NoContent() : Results.Ok(track); } private static async Task GetUserProfile( @@ -157,10 +161,9 @@ public static class UserEndpoints CurrentUser currentUser, long id, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 20) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } @@ -169,10 +172,9 @@ public static class UserEndpoints CurrentUser currentUser, long id, CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 20) + [AsParameters] PageQuery page) { - var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs index 33d2da5..d6e75ff 100644 --- a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs @@ -28,10 +28,10 @@ public static class ErrorOrHttpExtensions var error = errors[0]; return error.Type switch { - ErrorType.NotFound => Results.NotFound(error.Description), + ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound), ErrorType.Unauthorized => Results.Unauthorized(), - ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden), - ErrorType.Conflict => Results.Conflict(error.Description), + ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden), + ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict), _ => Results.Problem(error.Description) }; } diff --git a/backend/Hosts/Musify.Api/Models/PageQuery.cs b/backend/Hosts/Musify.Api/Models/PageQuery.cs new file mode 100644 index 0000000..888a064 --- /dev/null +++ b/backend/Hosts/Musify.Api/Models/PageQuery.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; +using Musify.Application.Shared; + +namespace Musify.Api.Models; + +/// Query-string binding for , keeping the camelCase parameter names of the API. +public record PageQuery( + [property: FromQuery(Name = "pageNumber")] int PageNumber = 1, + [property: FromQuery(Name = "pageSize")] int PageSize = 20); diff --git a/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs new file mode 100644 index 0000000..2ae656a --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Musify.Api.Models; +using Musify.Application.Shared; + +namespace Musify.Api.Validators; + +public sealed class PageQueryValidator : AbstractValidator +{ + public PageQueryValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1); + + RuleFor(x => x.PageSize) + .InclusiveBetween(1, PageRequest.MaxPageSize); + } +} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index b0003b7..c28c91e 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -13,5 +13,8 @@ public sealed class CreatePlayListRequestValidator : AbstractValidator x.Description) .MaximumLength(256); + + RuleFor(x => x.Visibility) + .IsInEnum(); } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index c886937..c7bbc96 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -15,5 +15,9 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator x.NewDescription) .MaximumLength(256) .When(x => x.NewDescription != null); + + RuleFor(x => x.NewVisibility) + .IsInEnum() + .When(x => x.NewVisibility != null); } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 49d0115..68745e8 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -95,7 +95,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) } [Fact] - public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() + public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden() { var owner = await CreateUserAsync(); var stranger = await CreateUserAsync(); @@ -106,7 +106,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture) var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index 9d27991..62dd90e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -38,7 +38,8 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { UserId = userId, TrackId = track.Id, - ListenedAt = listenedAt + ListenedAt = listenedAt, + IsCounted = true }); await database.SaveChangesAsync(TestContext.Current.CancellationToken); } @@ -94,7 +95,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) } [Fact] - public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound() + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent() { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); @@ -102,6 +103,39 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Theory] + [InlineData("pageSize=0")] + [InlineData("pageSize=101")] + [InlineData("pageNumber=0")] + public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query) + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetListeningStats_AnotherUser_ReturnsForbidden() + { + var userId = await fixture.SeedUserAsync(); + var other = await fixture.SeedUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetListeningStats_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs index 77caf56..784c48e 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -39,7 +39,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -49,7 +49,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -65,7 +65,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized() + public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var otherOwner = TestEntities.User(2, "other"); @@ -75,7 +75,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index 98c5d27..f6ab013 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -1,16 +1,21 @@ using ErrorOr; using Musify.Application.Albums; +using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums; public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase { - private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + private readonly IEventBus eventBus = Substitute.For(); + + private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); [Fact] - public async Task Handle_Owner_RemovesAlbum() + public async Task Handle_Owner_MarksAlbumAsRemoving() { var owner = TestEntities.User(); var album = TestEntities.Album(owner.Id); @@ -19,7 +24,10 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -31,7 +39,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum() + public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -40,7 +48,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs index 60a732d..4b84e3d 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -34,4 +34,19 @@ public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.AlbumPictures.Pending("original.png")); + await SeedAsync(owner, album); + + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Contains($"{owner.Id}", result.Value.Key); + Assert.EndsWith("original.png", result.Value.Key); + Assert.Equal("image/png", result.Value.ContentType); + } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index c792fe9..60be72f 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -46,7 +46,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -55,7 +55,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index bd46886..b1c6695 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -77,7 +77,7 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -88,6 +88,6 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs index bf5322f..fd3626b 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -53,7 +53,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -62,7 +62,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -89,4 +89,18 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } + + [Fact] + public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError() + { + 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 }); + Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 }); + + var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken); + + Assert.Equal("Test.Duplicate", result.FirstError.Code); + } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs index 7965b4e..d8559c7 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -39,7 +39,7 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -48,6 +48,6 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index 2acaa5b..c996a45 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -33,7 +33,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -42,7 +42,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -56,4 +56,26 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); + await SeedAsync( + owner, playList, first, second, third, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 }); + + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList(); + Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); + Assert.Equal([0, 1], remaining.Select(link => link.Position)); + } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index 356c69d..fc3c8e7 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -48,7 +48,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -59,7 +59,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] @@ -93,7 +93,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged() + public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); @@ -105,7 +105,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase Assert.False(result.IsError); Assert.Equal("Original Name", result.Value.Name); - Assert.Equal("Original description", result.Value.Description); + Assert.Null(result.Value.Description); } [Fact] @@ -126,7 +126,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName); + Assert.Null(result.Value.SmallImageKeyName); var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 6ddf063..c891090 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -1,3 +1,4 @@ +using ErrorOr; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; @@ -79,6 +80,10 @@ public sealed class TestDatabase : DbContext, IDatabase .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); + modelBuilder.Entity() .HasIndex(mix => new { mix.UserId, mix.Position }); @@ -164,6 +169,19 @@ public sealed class TestDatabase : DbContext, IDatabase return new SqliteDatabaseTransaction(transaction); } + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + { + return onUniqueViolation; + } + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index 00163a1..4052fe0 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -39,7 +39,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() + public async Task Handle_NotOwner_ReturnsForbidden() { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); @@ -48,7 +48,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs index 88965d9..822db75 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -1,4 +1,3 @@ -using ErrorOr; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; @@ -22,18 +21,18 @@ public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestB var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.False(result.IsError); - Assert.Equal("Newer listen", result.Value.Title); + Assert.NotNull(result); + Assert.Equal("Newer listen", result.Title); } [Fact] - public async Task Handle_NoHistory_ReturnsNotFound() + public async Task Handle_NoHistory_ReturnsNull() { var owner = TestEntities.User(); await SeedAsync(owner); var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + Assert.Null(result); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 9be31e2..2db7b57 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -27,19 +27,4 @@ public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase var track = Assert.Single(result.Value.Items); Assert.Equal("Mine", track.Title); } - - [Theory] - [InlineData(0, 1)] - [InlineData(-5, 1)] - public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); - - var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(expectedPageNumber, result.Value.PageNumber); - } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index ebca2ff..77a3850 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -102,7 +102,7 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_NoListenToday_StreakIsZero() + public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive() { var user = TestEntities.User(); var track = TestEntities.Track(user); @@ -112,6 +112,20 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(1, result.StreakDays); + } + + [Fact] + public async Task Handle_NoListenTodayOrYesterday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(0, result.StreakDays); } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index bee7c06..80bbcc1 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -42,12 +42,21 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + Assert.NotNull(stream); using var reader = new StreamReader(stream, Encoding.UTF8); var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); Assert.Equal("hello seaweedfs", readBack); } + [Fact] + public async Task GetFileAsync_MissingObject_ReturnsNull() + { + var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(stream); + } + [Fact] public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() { diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 7fe2d25..57fb27d 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -1396,6 +1396,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1430,6 +1437,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1465,6 +1479,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1507,6 +1528,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1825,6 +1853,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1957,6 +1992,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -1991,6 +2033,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2073,6 +2122,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2353,6 +2409,13 @@ export interface operations { }; content?: never; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not Found */ 404: { headers: { @@ -2561,8 +2624,8 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - /** @description Not Found */ - 404: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 8b94487..7c2eb72 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -3,7 +3,7 @@ export const SEARCH_DEBOUNCE_MS = 450; export const SEARCH_MIN_LENGTH = 2; export const INFINITE_SCROLL_ROOT_MARGIN = '300px'; export const ALBUMS_PAGE_SIZE = 50; -export const ALBUM_TRACKS_PAGE_SIZE = 200; +export const ALBUM_TRACKS_PAGE_SIZE = 100; export const ALBUM_EARLIEST_YEAR = 1877; export const LIBRARY_PICKER_PAGE_SIZE = 50; export const EXPLORE_ALBUMS_PAGE_SIZE = 10; @@ -15,7 +15,8 @@ export const HOME_CONTINUE_LIMIT = 10; export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5; export const HOME_POPULAR_MAX = 8; export const PLAYLIST_PICKER_PAGE_SIZE = 50; -export const LIKED_TRACKS_PAGE_SIZE = 500; +export const LIKED_TRACKS_LIMIT = 500; +export const PLAYLIST_TRACKS_LIMIT = 500; export const EXPLORE_USERS_LIMIT = 12; export const FOLLOW_LIST_PAGE_SIZE = 30; export const MENU_WIDTH = 240; diff --git a/web-player/src/lib/server/pagination.ts b/web-player/src/lib/server/pagination.ts new file mode 100644 index 0000000..f8805b1 --- /dev/null +++ b/web-player/src/lib/server/pagination.ts @@ -0,0 +1,22 @@ +/** Largest page size the API accepts (`PageRequest.MaxPageSize` in the backend). */ +export const API_MAX_PAGE_SIZE = 100; + +type Page = { items: T[]; hasNextPage: boolean }; + +/** Loads consecutive pages until the list ends or `maxItems` items were collected. */ +export async function fetchAllPages( + loadPage: (pageNumber: number, pageSize: number) => Promise | undefined>, + maxItems: number +): Promise { + const items: T[] = []; + + for (let pageNumber = 1; items.length < maxItems; pageNumber++) { + const page = await loadPage(pageNumber, API_MAX_PAGE_SIZE); + if (!page) break; + + items.push(...page.items); + if (!page.hasNextPage) break; + } + + return items.slice(0, maxItems); +} diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 5349245..8b41e64 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -7,6 +7,8 @@ import { unwrapOrError } from '$lib/server/api'; import { uploadPresignedImage } from '$lib/server/upload'; +import { fetchAllPages } from '$lib/server/pagination'; +import { PLAYLIST_TRACKS_LIMIT } from '$lib/config'; const MAX_NAME = 100; @@ -21,11 +23,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { const tracksByPlaylist = await Promise.all( data.items.map((playlist) => - api - .GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } } - }) - .then((res) => res.data?.items ?? []) + fetchAllPages( + async (pageNumber, pageSize) => + ( + await api.GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId: playlist.id }, query: { pageNumber, pageSize } } + }) + ).data, + PLAYLIST_TRACKS_LIMIT + ) ) ); diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index fd32656..32882b6 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -8,6 +8,8 @@ import { unwrapOrFail } from '$lib/server/api'; import { uploadPresignedImage } from '$lib/server/upload'; +import { fetchAllPages } from '$lib/server/pagination'; +import { PLAYLIST_TRACKS_LIMIT } from '$lib/config'; const MAX_NAME = 100; @@ -16,15 +18,20 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent optionalUser(locals, url, allowAnonymousListening); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const [playlistRes, tracksRes] = await Promise.all([ + const [playlistRes, tracks] = await Promise.all([ api.GET('/playlists/{id}', { params: { path: { id: params.id } } }), - api.GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } } - }) + fetchAllPages( + async (pageNumber, pageSize) => + ( + await api.GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId: params.id }, query: { pageNumber, pageSize } } + }) + ).data, + PLAYLIST_TRACKS_LIMIT + ) ]); const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404); - const tracks = tracksRes.data?.items ?? []; return { playlist, tracks }; }; diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index caf325d..98e3283 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; import { createApiClient } from '$lib/server/api'; import { authConfig } from '$lib/server/config'; -import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config'; +import { LIKED_TRACKS_LIMIT } from '$lib/config'; +import { fetchAllPages } from '$lib/server/pagination'; const PUBLIC_PATHS = new Set(['/auth']); const SIDEBAR_PLAYLISTS_LIMIT = 8; @@ -30,10 +31,11 @@ async function fetchSidebarPlaylists( async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) { try { const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined }); - const { data } = await api.GET('/likes', { - params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } } - }); - return data?.items ?? []; + return await fetchAllPages( + async (pageNumber, pageSize) => + (await api.GET('/likes', { params: { query: { pageNumber, pageSize } } })).data, + LIKED_TRACKS_LIMIT + ); } catch { return []; }