Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete

This commit is contained in:
2026-09-24 05:14:05 +02:00
parent ab388e6ad6
commit 0e3576922f
90 changed files with 2083 additions and 322 deletions
@@ -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.");
}
@@ -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);
@@ -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<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> 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;
}
}
@@ -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,
@@ -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<ErrorOr<AlbumCoverLocation>> 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);
@@ -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);
@@ -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))
{
@@ -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);
@@ -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,
@@ -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)
@@ -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);
}
@@ -1,3 +1,4 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities;
@@ -38,4 +39,6 @@ public interface IDatabase
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
}
@@ -2,7 +2,7 @@ namespace Musify.Application.Contracts;
public interface IStorageService
{
public Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
@@ -0,0 +1,5 @@
namespace Musify.Application.Events;
public record DeleteAlbumEvent(
Guid AlbumId,
long UserId);
@@ -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);
@@ -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);
@@ -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);
@@ -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<MixApplicationResponse>(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;
@@ -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);
@@ -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<ErrorOr<Success>> 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.");
}
@@ -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<ErrorOr<Success>> PublishCreatePlayListEventAsync(
@@ -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;
@@ -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,
@@ -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<ErrorOr<PlayListCoverLocation>> 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);
@@ -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);
@@ -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,
@@ -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<ErrorOr<Success>> 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;
}
}
@@ -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);
}
@@ -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);
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Shared;
public record PageRequest(int PageNumber = 1, int PageSize = 20)
{
public const int MaxPageSize = 100;
}
@@ -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)
@@ -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<ErrorOr<TrackApplicationResponse>>;
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
public async ValueTask<TrackApplicationResponse?> 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;
}
}
@@ -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));
}
@@ -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);
@@ -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);
@@ -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))
{
@@ -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<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> 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<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
@@ -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);
@@ -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++;
@@ -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 };
}
@@ -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 };
}
@@ -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<DeleteAlbumFromDbActivity> logger)
: IExecuteActivity<DeleteAlbumFromDbArguments>
{
public const string ExecuteEndpointName = "delete-album-from-db";
public async Task<ExecutionResult> Execute(ExecuteContext<DeleteAlbumFromDbArguments> 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();
}
}
@@ -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<MarkAlbumAsRemovingActivity> logger)
: IExecuteActivity<MarkAlbumAsRemovingArguments>
{
public const string ExecuteEndpointName = "mark-album-as-removing";
public async Task<ExecutionResult> Execute(ExecuteContext<MarkAlbumAsRemovingArguments> 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();
}
}
@@ -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))
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
internal record DeleteAlbumFromDbArguments(
Guid AlbumId);
@@ -0,0 +1,4 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
internal record MarkAlbumAsRemovingArguments(
Guid AlbumId);
@@ -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<DeleteAlbumEvent>
{
public async Task Consume(ConsumeContext<DeleteAlbumEvent> consumeContext)
{
var routingSlip = (await routingSlipBuilder
.BuildAsync(
consumeContext.Message.AlbumId,
consumeContext.Message.UserId,
consumeContext.CorrelationId,
consumeContext.CancellationToken))
.Build();
await bus.Execute(routingSlip);
}
}
@@ -22,6 +22,7 @@ public static partial class MassTransitDependencyInjection
services.AddScoped<PictureWorkflowRoutingSlipBuilder>();
services.AddScoped<DeleteTrackRoutingSlipBuilder>();
services.AddScoped<DeletePlayListRoutingSlipBuilder>();
services.AddScoped<DeleteAlbumRoutingSlipBuilder>();
services.AddScoped<CreateTrackRoutingSlipBuilder>();
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
services.AddScoped<AlbumPictureSourceRoutingSlipBuilder>();
@@ -35,6 +36,7 @@ public static partial class MassTransitDependencyInjection
options.AddConsumer<UpdateTrackAudioConsumer>();
options.AddConsumer<DeleteTrackConsumer>();
options.AddConsumer<DeletePlayListConsumer>();
options.AddConsumer<DeleteAlbumConsumer>();
options.AddConsumer<CreateTrackConsumer>();
options.AddConsumer<CreatePlayListConsumer>();
options.AddConsumer<UpdatePlayListPictureSourceConsumer>();
@@ -58,6 +60,8 @@ public static partial class MassTransitDependencyInjection
options.AddExecuteActivity<MarkTrackAsFailedActivity, MarkTrackAsFailedArguments>();
options.AddExecuteActivity<MarkPlayListAsFailedActivity, MarkPlayListAsFailedArguments>();
options.AddExecuteActivity<MarkAlbumAsFailedActivity, MarkAlbumAsFailedArguments>();
options.AddExecuteActivity<MarkAlbumAsRemovingActivity, MarkAlbumAsRemovingArguments>();
options.AddExecuteActivity<DeleteAlbumFromDbActivity, DeleteAlbumFromDbArguments>();
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
@@ -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);
@@ -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);
@@ -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";
}
@@ -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<RoutingSlipBuilder> 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;
}
}
@@ -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<Album>().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<PlayListHasTrack>()
.HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId })
.IsUnique();
modelBuilder.Entity<Mix>()
.HasIndex(mix => new { mix.UserId, mix.Position });
@@ -205,6 +211,19 @@ public class Database(DatabaseConfiguration configuration)
return new DatabaseTransaction(transaction);
}
public async Task<ErrorOr<Success>> 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) =>
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class PlayListTrackUnique : Migration
{
/// <inheritdoc />
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);
}
/// <inheritdoc />
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");
}
}
}
@@ -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<string>("LargeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
@@ -808,7 +807,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName");
@@ -15,7 +15,7 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
InfrastructureStorageConfiguration storageClientConfiguration)
: IStorageService
{
public async Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
public async Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new GetObjectRequest
{
@@ -29,6 +29,11 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key);
return response.ResponseStream;
}
catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey")
{
logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key);
return null;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);