Add track publication, album types, optional track pictures and batch upload URLs

Tracks
- Track.IsPublished (migration marks existing tracks as published). New
  tracks are created unpublished.
- PUT /tracks/{id}/published and PUT /tracks/published change the flag;
  publishing requires completed audio processing (the batch endpoint
  reports skipped tracks).
- An unpublished track is only visible to its owner. A WherePublished /
  VisibleTo filter is applied to search, genres, listening history, last
  listened, likes, mixes, album and playlist contents and counts, recently
  listened albums, public user tracks, track details and stream tickets.
- isPublished is part of the track responses, GET /tracks/users/{userId}
  accepts a published filter and status-counts reports published and
  unpublished totals.
- CreateTrackRequest.pictureIntentId is optional: without it the track
  uses the album cover or the preset picture and no picture processing
  runs. CreateTrackResourcesEvent carries nullable picture fields, so
  queues must be drained before deploying.
- POST /tracks/upload-urls accepts an audios list and one optional picture
  shared by all of them, and returns an intent per file. The upload intent
  limits are raised so a batch fits.

Albums
- AlbumType (Album, EP, Single) on create, update and responses.
- CreateAlbumRequest.trackIds creates the album and links the tracks in a
  single save.
This commit is contained in:
2026-09-25 01:37:55 +02:00
parent d839a72700
commit ebfd41b160
79 changed files with 3076 additions and 151 deletions
@@ -19,7 +19,9 @@ namespace Musify.Application.Albums
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear, int? ReleaseYear,
Guid PictureIntentId) Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler( public class CreateAlbumCommandHandler(
@@ -38,6 +40,21 @@ namespace Musify.Application.Albums
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", request.UserId); return AppErrors.NotFound("User", request.UserId);
var trackIds = (request.TrackIds ?? []).Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId, track.IsPublished })
.ToListAsync(cancellationToken);
var missing = trackIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var prepared = await PictureSourceChange.PrepareAsync( var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError) if (prepared.IsError)
@@ -53,11 +70,19 @@ namespace Musify.Application.Albums
NormalizedTitle = TextNormalizer.Normalize(title), NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description, Description = request.Description,
ReleaseYear = request.ReleaseYear, ReleaseYear = request.ReleaseYear,
Type = request.Type,
Pictures = picture.Pictures Pictures = picture.Pictures
}; };
await database.Albums.AddAsync(album, cancellationToken); await database.Albums.AddAsync(album, cancellationToken);
for (var index = 0; index < trackIds.Count; index++)
{
await database.AlbumHasTracks.AddAsync(
new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
cancellationToken);
}
await eventBus.PublishAsync( await eventBus.PublishAsync(
new CreateAlbumResourcesEvent( new CreateAlbumResourcesEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
@@ -65,9 +90,15 @@ namespace Musify.Application.Albums
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0); var published = tracks.Where(track => track.IsPublished).Select(track => track.Id).ToHashSet();
var visibleTrackIds = trackIds.Where(published.Contains).ToList();
return AlbumApplicationResponse.FromEntity(
album,
visibleTrackIds.Count,
[.. visibleTrackIds.Take(AlbumProjections.CoverTrackCount)]);
} }
} }
} }
@@ -28,7 +28,7 @@ namespace Musify.Application.Albums
return await database.AlbumHasTracks return await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id) .ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track) .Select(albumTrack => albumTrack.Track)
@@ -20,6 +20,7 @@ namespace Musify.Application.Albums
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Join( .Join(
listens, listens,
albumTrack => albumTrack.TrackId, albumTrack => albumTrack.TrackId,
@@ -1,4 +1,5 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses
{ {
@@ -14,7 +15,8 @@ namespace Musify.Application.Albums.Responses
string? LargeImageKeyName, string? LargeImageKeyName,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds,
AlbumType Type)
{ {
public const int CoverTrackCount = AlbumProjections.CoverTrackCount; public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
@@ -35,7 +37,8 @@ namespace Musify.Application.Albums.Responses
album.Pictures?.LargeName, album.Pictures?.LargeName,
album.CreatedAt, album.CreatedAt,
album.UpdatedAt, album.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? [],
album.Type);
} }
} }
} }
@@ -17,16 +17,18 @@ namespace Musify.Application.Albums.Responses
album.Description, album.Description,
album.ReleaseYear, album.ReleaseYear,
album.OwnerUserId, album.OwnerUserId,
album.AlbumTracks.Count, album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
album.Pictures!.SmallName, album.Pictures!.SmallName,
album.Pictures!.MediumName, album.Pictures!.MediumName,
album.Pictures!.LargeName, album.Pictures!.LargeName,
album.CreatedAt, album.CreatedAt,
album.UpdatedAt, album.UpdatedAt,
album.AlbumTracks album.AlbumTracks
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount) .Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId) .Select(albumTrack => albumTrack.TrackId)
.ToList())); .ToList(),
album.Type));
} }
} }
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums namespace Musify.Application.Albums
{ {
@@ -13,7 +14,8 @@ namespace Musify.Application.Albums
Guid AlbumId, Guid AlbumId,
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear) int? ReleaseYear,
AlbumType? Type = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler( public class UpdateAlbumCommandHandler(
@@ -34,6 +36,7 @@ namespace Musify.Application.Albums
album.NormalizedTitle = TextNormalizer.Normalize(title); album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(); album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
album.ReleaseYear = request.ReleaseYear; album.ReleaseYear = request.ReleaseYear;
album.Type = request.Type ?? album.Type;
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
@@ -10,10 +10,10 @@ namespace Musify.Application.Configuration
public int UploadUrlExpiresInSeconds { get; set; } = 120; public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)] [Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5; public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
@@ -4,11 +4,11 @@ namespace Musify.Application.Events
{ {
public record CreateTrackResourcesEvent( public record CreateTrackResourcesEvent(
Guid TrackId, Guid TrackId,
Guid PictureIntentId, Guid? PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string? PictureSourceKey,
string PictureDestinationKey, string? PictureDestinationKey,
string AudioSourceKey, string AudioSourceKey,
string AudioDestinationKey, string AudioDestinationKey,
string AudioProcessedFolderKey, string AudioProcessedFolderKey,
@@ -15,7 +15,7 @@ namespace Musify.Application.Genres
{ {
var rows = await database.TrackTags var rows = await database.TrackTags
.AsNoTracking() .AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
.GroupBy(tag => tag.Tag) .GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -20,7 +20,7 @@ namespace Musify.Application.Likes
{ {
return await database.TrackLikes return await database.TrackLikes
.AsNoTracking() .AsNoTracking()
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished)
.OrderByDescending(like => like.CreatedAt) .OrderByDescending(like => like.CreatedAt)
.ThenBy(like => like.Id) .ThenBy(like => like.Id)
.Select(like => like.Track) .Select(like => like.Track)
@@ -21,7 +21,7 @@ namespace Musify.Application.Likes
{ {
var trackExists = await database.Tracks var trackExists = await database.Tracks
.AsNoTracking() .AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken);
if (!trackExists) if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId); return AppErrors.NotFound("Track", request.TrackId);
@@ -25,7 +25,7 @@ namespace Musify.Application.Mixes
var libraryTrackIds = await database.Tracks var libraryTrackIds = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id) .Select(track => track.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -25,7 +25,13 @@ namespace Musify.Application.Mixes
mix => mix.Id, mix => mix.Id,
mix => mix.Items.OrderBy(item => item.Position).ToList()); mix => mix.Items.OrderBy(item => item.Position).ToList());
var coverItems = orderedItemsByMix.Values var visibleTrackIds = await MixItemMapper.VisibleTrackIdsAsync(
database, orderedItemsByMix.Values.SelectMany(items => items), cancellationToken);
var visibleItemsByMix = orderedItemsByMix.ToDictionary(
entry => entry.Key,
entry => entry.Value.Where(item => visibleTrackIds.Contains(item.TrackId)).ToList());
var coverItems = visibleItemsByMix.Values
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
.ToList(); .ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
@@ -35,7 +41,7 @@ namespace Musify.Application.Mixes
var responses = new List<MixApplicationResponse>(mixes.Count); var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes) foreach (var mix in mixes)
{ {
var orderedItems = orderedItemsByMix[mix.Id]; var orderedItems = visibleItemsByMix[mix.Id];
var covers = orderedItems var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount) .Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId)) .Where(item => coverResponses.ContainsKey(item.TrackId))
@@ -9,6 +9,22 @@ namespace Musify.Application.Mixes
internal static class MixItemMapper internal static class MixItemMapper
{ {
public static async Task<HashSet<Guid>> VisibleTrackIdsAsync(
IDatabase database,
IEnumerable<MixItem> items,
CancellationToken cancellationToken)
{
var trackIds = items.Select(item => item.TrackId).Distinct().ToList();
var visible = await database.Tracks
.AsNoTracking()
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
return [.. visible];
}
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync( public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database, IDatabase database,
IReadOnlyList<MixItem> orderedItems, IReadOnlyList<MixItem> orderedItems,
@@ -19,7 +35,7 @@ namespace Musify.Application.Mixes
var tracksById = await database.Tracks var tracksById = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Include(track => track.Owner) .Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken); .ToDictionaryAsync(x => x.Track.Id, cancellationToken);
@@ -35,10 +35,7 @@ namespace Musify.Application.Pictures
{ {
var (source, routes) = request.Owner switch var (source, routes) = request.Owner switch
{ {
CoverOwner.Track => (await database.Tracks.AsNoTracking() CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
CoverOwner.Album => (await database.Albums.AsNoTracking() CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
@@ -71,5 +68,34 @@ namespace Musify.Application.Pictures
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
} }
private async Task<(CoverSource? Source, PictureRoutes Routes)> FindTrackSourceAsync(Guid trackId, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == trackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new
{
Own = new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName),
Album = t.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Album.Pictures != null)
.OrderBy(albumTrack => albumTrack.AddedAt)
.ThenBy(albumTrack => albumTrack.AlbumId)
.Select(albumTrack => new CoverSource(
albumTrack.Album.OwnerUserId,
albumTrack.Album.Pictures!.OriginalName,
albumTrack.Album.Pictures.SmallName,
albumTrack.Album.Pictures.MediumName,
albumTrack.Album.Pictures.LargeName))
.FirstOrDefault()
})
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return (null, trackConfiguration.Routes);
return string.IsNullOrEmpty(track.Own.Original) && track.Album != null
? (track.Album, albumConfiguration.Routes)
: (track.Own, trackConfiguration.Routes);
}
} }
} }
@@ -34,7 +34,7 @@ namespace Musify.Application.PlayLists
var albumTrackIds = await database.AlbumHasTracks var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId) .Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -27,7 +27,7 @@ namespace Musify.Application.PlayLists
var existingIds = await database.Tracks var existingIds = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
.Select(track => track.Id) .Select(track => track.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists
return await database.PlayListHasTracks return await database.PlayListHasTracks
.AsNoTracking() .AsNoTracking()
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position) .OrderBy(playListTrack => playListTrack.Position)
.ThenBy(playListTrack => playListTrack.Id) .ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track) .Select(playListTrack => playListTrack.Track)
@@ -25,14 +25,15 @@ namespace Musify.Application.PlayLists.Responses
playList.CreatedAt, playList.CreatedAt,
playList.UpdatedAt, playList.UpdatedAt,
playList.PlayListTracks playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position) .OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount) .Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId) .Select(playListTrack => playListTrack.TrackId)
.ToList(), .ToList(),
playList.OwnerUserId, playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
playList.PlayListTracks playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.Sum(playListTrack => playListTrack.Track.DurationSeconds))); .Sum(playListTrack => playListTrack.Track.DurationSeconds)));
} }
} }
@@ -15,7 +15,8 @@ namespace Musify.Application.Services
PictureRoutes Routes, PictureRoutes Routes,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes); long? ExpectedSizeBytes,
int? SharedKeyGroup = null);
public sealed record IssuedUpload( public sealed record IssuedUpload(
Guid IntentId, Guid IntentId,
@@ -43,11 +44,15 @@ namespace Musify.Application.Services
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", userId); return AppErrors.NotFound("User", userId);
var sharedKeys = new Dictionary<int, string>();
var planned = requests var planned = requests
.Select(request => .Select(request =>
{ {
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
if (request.SharedKeyGroup is { } group)
key = sharedKeys.TryAdd(group, key) ? key : sharedKeys[group];
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
return (Request: request, ObjectName: objectName, Key: key, Size: size); return (Request: request, ObjectName: objectName, Key: key, Size: size);
}) })
@@ -16,7 +16,7 @@ namespace Musify.Application.Tracks
public record CreateTrackCommand( public record CreateTrackCommand(
long UserId, long UserId,
string Title, string Title,
Guid PictureIntentId, Guid? PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false) bool IsExplicit = false)
@@ -45,20 +45,27 @@ namespace Musify.Application.Tracks
if (request.PictureIntentId == request.AudioIntentId) if (request.PictureIntentId == request.AudioIntentId)
return Error.Validation(description: "Picture and audio must use different upload intents."); return Error.Validation(description: "Picture and audio must use different upload intents.");
UploadIntent? pictureIntent = null;
if (request.PictureIntentId is { } pictureIntentId)
{
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); pictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
if (pictureValidation.IsError) if (pictureValidation.IsError)
return pictureValidation.Errors; return pictureValidation.Errors;
pictureIntent = pictureValidation.Value;
}
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
if (audioValidation.IsError) if (audioValidation.IsError)
return audioValidation.Errors; return audioValidation.Errors;
var pictureIntent = pictureValidation.Value;
var audioIntent = audioValidation.Value; var audioIntent = audioValidation.Value;
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); var finalPictureKey = pictureIntent == null
? null
: trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
@@ -71,11 +78,11 @@ namespace Musify.Application.Tracks
IsExplicit = request.IsExplicit, IsExplicit = request.IsExplicit,
Pictures = new TrackPictures Pictures = new TrackPictures
{ {
OriginalName = pictureIntent.ObjectName, OriginalName = pictureIntent?.ObjectName,
SmallName = trackConfiguration.Routes.PresetSmallPicture, SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture, MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture, LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending ProcessingStatus = pictureIntent == null ? ProcessingStatus.Completed : ProcessingStatus.Pending
}, },
Audio = new TrackAudio Audio = new TrackAudio
{ {
@@ -94,10 +101,10 @@ namespace Musify.Application.Tracks
await eventBus.PublishAsync( await eventBus.PublishAsync(
new CreateTrackResourcesEvent( new CreateTrackResourcesEvent(
trackEntity.Id, trackEntity.Id,
pictureIntent.Id, pictureIntent?.Id,
audioIntent.Id, audioIntent.Id,
pictureIntent.Bucket, audioIntent.Bucket,
pictureIntent.Key, pictureIntent?.Key,
finalPictureKey, finalPictureKey,
audioIntent.Key, audioIntent.Key,
finalAudioKey, finalAudioKey,
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
.AsNoTracking() .AsNoTracking()
.Include(t => t.Track.Owner) .Include(t => t.Track.Owner)
.Include(t => t.Track.Tags) .Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished)
.OrderByDescending(t => t.ListenedAt) .OrderByDescending(t => t.ListenedAt)
.ThenBy(t => t.Id) .ThenBy(t => t.Id)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
@@ -27,6 +27,7 @@ namespace Musify.Application.Tracks
var tracksById = await database.Tracks var tracksById = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Active() .Active()
.WherePublished()
.Where(t => recentTrackIds.Contains(t.Id)) .Where(t => recentTrackIds.Contains(t.Id))
.SelectResponse() .SelectResponse()
.ToDictionaryAsync(t => t.Id, cancellationToken); .ToDictionaryAsync(t => t.Id, cancellationToken);
@@ -7,7 +7,7 @@ using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
public record GetTrackByIdQuery(Guid TrackId) public record GetTrackByIdQuery(Guid TrackId, long? ViewerId = null)
: IQuery<ErrorOr<TrackApplicationResponse>>; : IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetTrackByIdQueryHandler(IDatabase database) public class GetTrackByIdQueryHandler(IDatabase database)
@@ -18,6 +18,7 @@ namespace Musify.Application.Tracks
var track = await database.Tracks var track = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Active() .Active()
.VisibleTo(request.ViewerId)
.Where(t => t.Id == request.TrackId) .Where(t => t.Id == request.TrackId)
.SelectResponse() .SelectResponse()
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
@@ -18,18 +18,23 @@ namespace Musify.Application.Tracks
.AsNoTracking() .AsNoTracking()
.Active() .Active()
.Where(track => track.OwnerUserId == request.UserId) .Where(track => track.OwnerUserId == request.UserId)
.GroupBy(track => track.Audio.TranscodeStatus) .GroupBy(track => new { track.Audio.TranscodeStatus, track.IsPublished })
.Select(group => new { Status = group.Key, Count = group.Count() }) .Select(group => new { group.Key.TranscodeStatus, group.Key.IsPublished, Count = group.Count() })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
int CountOf(ProcessingStatus status) => counts.Where(c => c.Status == status).Sum(c => c.Count); int CountOf(ProcessingStatus status) => counts.Where(c => c.TranscodeStatus == status).Sum(c => c.Count);
var published = counts.Where(c => c.IsPublished).Sum(c => c.Count);
var total = counts.Sum(c => c.Count);
return new TrackStatusCountsResponse( return new TrackStatusCountsResponse(
counts.Sum(c => c.Count), total,
CountOf(ProcessingStatus.Pending), CountOf(ProcessingStatus.Pending),
CountOf(ProcessingStatus.Processing), CountOf(ProcessingStatus.Processing),
CountOf(ProcessingStatus.Completed), CountOf(ProcessingStatus.Completed),
CountOf(ProcessingStatus.Failed)); CountOf(ProcessingStatus.Failed),
published,
total - published);
} }
} }
} }
@@ -17,7 +17,8 @@ namespace Musify.Application.Tracks
{ {
var tracksQuery = database.Tracks var tracksQuery = database.Tracks
.AsNoTracking() .AsNoTracking()
.Active(); .Active()
.WherePublished();
if (!string.IsNullOrWhiteSpace(request.Name)) if (!string.IsNullOrWhiteSpace(request.Name))
{ {
@@ -13,7 +13,9 @@ namespace Musify.Application.Tracks
string? Name, string? Name,
int PageNumber, int PageNumber,
int PageSize, int PageSize,
IReadOnlyList<ProcessingStatus>? Statuses = null) IReadOnlyList<ProcessingStatus>? Statuses = null,
long? ViewerId = null,
bool? IsPublished = null)
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
public class GetTracksByUserIdQueryHandler(IDatabase database) public class GetTracksByUserIdQueryHandler(IDatabase database)
@@ -26,6 +28,9 @@ namespace Musify.Application.Tracks
.Active() .Active()
.Where(track => track.OwnerUserId == request.UserId); .Where(track => track.OwnerUserId == request.UserId);
if (request.ViewerId != request.UserId)
tracksQuery = tracksQuery.WherePublished();
if (!string.IsNullOrWhiteSpace(request.Name)) if (!string.IsNullOrWhiteSpace(request.Name))
{ {
var normalizedName = TextNormalizer.Normalize(request.Name); var normalizedName = TextNormalizer.Normalize(request.Name);
@@ -38,6 +43,9 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus)); tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus));
} }
if (request.IsPublished is { } isPublished)
tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished);
return await tracksQuery return await tracksQuery
.OrderByDescending(track => track.CreatedAt) .OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id) .ThenBy(track => track.Id)
@@ -7,14 +7,17 @@ using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
public record AudioUploadSpec(string FileType, string ContentType, long? ExpectedSizeBytes = null);
public record RequestTrackUploadUrlsCommand( public record RequestTrackUploadUrlsCommand(
long UserId, long UserId,
string PictureFileType, string? PictureFileType,
string PictureContentType, string? PictureContentType,
string AudioFileType, string? AudioFileType = null,
string AudioContentType, string? AudioContentType = null,
long? ExpectedPictureSizeBytes = null, long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null) long? ExpectedAudioSizeBytes = null,
IReadOnlyList<AudioUploadSpec>? Audios = null)
: ICommand<ErrorOr<TrackUploadUrlsResponse>>; : ICommand<ErrorOr<TrackUploadUrlsResponse>>;
public class RequestTrackUploadUrlsCommandHandler( public class RequestTrackUploadUrlsCommandHandler(
@@ -24,35 +27,75 @@ namespace Musify.Application.Tracks
UploadIntentConfiguration uploadIntentConfiguration) UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>> : ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
{ {
private const int PictureKeyGroup = 1;
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
{ {
var audios = ResolveAudios(request);
if (audios.Count == 0)
return Error.Validation(description: "At least one audio file is required.");
var hasPicture = request.PictureFileType != null && request.PictureContentType != null;
var routes = trackConfiguration.Routes; var routes = trackConfiguration.Routes;
var issued = await issuer.IssueAsync(
request.UserId, var uploads = new List<UploadRequest>();
[ if (hasPicture)
new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), {
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) for (var index = 0; index < audios.Count; index++)
], {
cancellationToken); uploads.Add(new UploadRequest(
UploadIntentPurpose.TrackPicture, routes, request.PictureFileType!, request.PictureContentType!,
request.ExpectedPictureSizeBytes, PictureKeyGroup));
}
}
uploads.AddRange(audios.Select(audio =>
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, audio.FileType, audio.ContentType, audio.ExpectedSizeBytes)));
var issued = await issuer.IssueAsync(request.UserId, uploads, cancellationToken);
if (issued.IsError) if (issued.IsError)
return issued.Errors; return issued.Errors;
var picture = issued.Value[0]; var pictures = hasPicture ? issued.Value.Take(audios.Count).ToList() : [];
var audio = issued.Value[1]; var audioUploads = issued.Value.Skip(pictures.Count).ToList();
return new TrackUploadUrlsResponse( var items = audioUploads
picture.IntentId, .Select((audio, index) => new TrackAudioUpload(
audio.IntentId, audio.IntentId,
storageConfiguration.Bucket, hasPicture ? pictures[index].IntentId : null,
picture.Key,
picture.ObjectName,
picture.ContentType,
picture.UploadUrl,
audio.Key, audio.Key,
audio.ObjectName, audio.ObjectName,
audio.ContentType, audio.ContentType,
audio.UploadUrl, audio.UploadUrl))
uploadIntentConfiguration.UploadUrlExpiresInSeconds); .ToList();
var firstPicture = pictures.Count > 0 ? pictures[0] : null;
var firstAudio = audioUploads[0];
return new TrackUploadUrlsResponse(
firstPicture?.IntentId,
firstAudio.IntentId,
storageConfiguration.Bucket,
firstPicture?.Key,
firstPicture?.ObjectName,
firstPicture?.ContentType,
firstPicture?.UploadUrl,
firstAudio.Key,
firstAudio.ObjectName,
firstAudio.ContentType,
firstAudio.UploadUrl,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
items);
}
private static IReadOnlyList<AudioUploadSpec> ResolveAudios(RequestTrackUploadUrlsCommand request)
{
if (request.Audios is { Count: > 0 } audios)
return audios;
return request.AudioFileType != null && request.AudioContentType != null
? [new AudioUploadSpec(request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)]
: [];
} }
} }
} }
@@ -0,0 +1,6 @@
namespace Musify.Application.Tracks.Responses
{
public record SetTracksPublishedResponse(
IReadOnlyList<Guid> UpdatedTrackIds,
IReadOnlyList<Guid> SkippedTrackIds);
}
@@ -14,7 +14,8 @@ namespace Musify.Application.Tracks.Responses
DateTime UpdatedAt, DateTime UpdatedAt,
long OwnerUserId, long OwnerUserId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags,
bool IsExplicit) bool IsExplicit,
bool IsPublished)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( new(
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
track.UpdatedAt, track.UpdatedAt,
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(), track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit); track.IsExplicit,
track.IsPublished);
} }
} }
@@ -8,6 +8,12 @@ namespace Musify.Application.Tracks.Responses
public static IQueryable<Track> Active(this IQueryable<Track> query) => public static IQueryable<Track> Active(this IQueryable<Track> query) =>
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<Track> WherePublished(this IQueryable<Track> query) =>
query.Where(track => track.IsPublished);
public static IQueryable<Track> VisibleTo(this IQueryable<Track> query, long? viewerId) =>
query.Where(track => track.IsPublished || track.OwnerUserId == viewerId);
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) => public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
query.Select(track => new TrackApplicationResponse( query.Select(track => new TrackApplicationResponse(
track.Id, track.Id,
@@ -20,7 +26,8 @@ namespace Musify.Application.Tracks.Responses
track.UpdatedAt, track.UpdatedAt,
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(), track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit)); track.IsExplicit,
track.IsPublished));
public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query) => public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query) =>
query.Select(track => new UserTrackApplicationResponse( query.Select(track => new UserTrackApplicationResponse(
@@ -35,6 +42,7 @@ namespace Musify.Application.Tracks.Responses
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(), track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit, track.IsExplicit,
track.IsPublished,
track.AlbumTracks track.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active) .Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.AddedAt) .OrderBy(albumTrack => albumTrack.AddedAt)
@@ -5,5 +5,7 @@ namespace Musify.Application.Tracks.Responses
int Pending, int Pending,
int Processing, int Processing,
int Completed, int Completed,
int Failed); int Failed,
int Published,
int Unpublished);
} }
@@ -1,16 +1,25 @@
namespace Musify.Application.Tracks.Responses namespace Musify.Application.Tracks.Responses
{ {
public record TrackAudioUpload(
Guid IntentId,
Guid? PictureIntentId,
string Key,
string Name,
string ContentType,
string UploadUrl);
public record TrackUploadUrlsResponse( public record TrackUploadUrlsResponse(
Guid PictureIntentId, Guid? PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
string Bucket, string Bucket,
string PictureKey, string? PictureKey,
string PictureName, string? PictureName,
string PictureContentType, string? PictureContentType,
string PictureUploadUrl, string? PictureUploadUrl,
string AudioKey, string AudioKey,
string AudioName, string AudioName,
string AudioContentType, string AudioContentType,
string AudioUploadUrl, string AudioUploadUrl,
int ExpiresInSeconds); int ExpiresInSeconds,
IReadOnlyList<TrackAudioUpload> Audios);
} }
@@ -16,5 +16,6 @@ namespace Musify.Application.Tracks.Responses
long OwnerUserId, long OwnerUserId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags,
bool IsExplicit, bool IsExplicit,
bool IsPublished,
IReadOnlyList<TrackAlbumReference> Albums); IReadOnlyList<TrackAlbumReference> Albums);
} }
@@ -0,0 +1,38 @@
using ErrorOr;
using Mediator;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Tracks
{
public record SetTrackPublishedCommand(long UserId, Guid TrackId, bool IsPublished)
: ICommand<ErrorOr<Success>>;
public class SetTrackPublishedCommandHandler(
IDatabase database,
ILogger<SetTrackPublishedCommandHandler> logger)
: ICommandHandler<SetTrackPublishedCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(SetTrackPublishedCommand request, CancellationToken cancellationToken)
{
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var track = found.Value;
if (request.IsPublished && !track.Audio.IsProcessed)
return Error.Validation("Track.NotProcessed", "Only tracks whose audio finished processing can be published.");
if (track.IsPublished == request.IsPublished)
return Result.Success;
track.IsPublished = request.IsPublished;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("{Action} track {TrackId}", request.IsPublished ? "Published" : "Unpublished", track.Id);
return Result.Success;
}
}
}
@@ -0,0 +1,58 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record SetTracksPublishedCommand(long UserId, IReadOnlyList<Guid> TrackIds, bool IsPublished)
: ICommand<ErrorOr<SetTracksPublishedResponse>>;
public class SetTracksPublishedCommandHandler(
IDatabase database,
ILogger<SetTracksPublishedCommandHandler> logger)
: ICommandHandler<SetTracksPublishedCommand, ErrorOr<SetTracksPublishedResponse>>
{
public async ValueTask<ErrorOr<SetTracksPublishedResponse>> Handle(SetTracksPublishedCommand request, CancellationToken cancellationToken)
{
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.ToListAsync(cancellationToken);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var updated = new List<Guid>();
var skipped = new List<Guid>();
foreach (var trackId in requestedIds)
{
var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId);
if (track == null || (request.IsPublished && !track.Audio.IsProcessed))
{
skipped.Add(trackId);
continue;
}
track.IsPublished = request.IsPublished;
updated.Add(trackId);
}
if (updated.Count > 0)
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("{Action} {Count} tracks of user {UserId}",
request.IsPublished ? "Published" : "Unpublished", updated.Count, request.UserId);
}
return new SetTracksPublishedResponse(updated, skipped);
}
}
}
@@ -30,6 +30,7 @@ namespace Musify.Application.Tracks
var track = await database.Tracks.AsNoTracking() var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.VisibleTo(request.UserId)
.Select(t => new { t.Id, t.Audio }) .Select(t => new { t.Id, t.Audio })
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
@@ -22,6 +22,8 @@ namespace Musify.Domain.Entities
public int? ReleaseYear { get; set; } public int? ReleaseYear { get; set; }
public AlbumType Type { get; set; }
public EntityPictures? Pictures { get; set; } public EntityPictures? Pictures { get; set; }
public required long OwnerUserId { get; set; } public required long OwnerUserId { get; set; }
@@ -28,6 +28,8 @@ namespace Musify.Domain.Entities
public bool IsExplicit { get; set; } public bool IsExplicit { get; set; }
public bool IsPublished { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
namespace Musify.Domain.ValueObjects
{
public enum AlbumType
{
Album = 0,
EP = 1,
[SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "Part of the public API contract.")]
Single = 2
}
}
@@ -16,6 +16,8 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
{ {
var arguments = executeContext.Arguments; var arguments = executeContext.Arguments;
if (arguments.FinalPictureKey != null)
{
await publishEndpoint.Publish( await publishEndpoint.Publish(
new UpdateTrackPictureEvent( new UpdateTrackPictureEvent(
arguments.TrackId, arguments.TrackId,
@@ -23,6 +25,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
arguments.FinalPictureKey, arguments.FinalPictureKey,
arguments.Sizes), arguments.Sizes),
executeContext.CancellationToken); executeContext.CancellationToken);
}
await publishEndpoint.Publish( await publishEndpoint.Publish(
new UpdateTrackAudioEvent( new UpdateTrackAudioEvent(
@@ -5,7 +5,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments
public record PublishTrackProcessingEventsArguments( public record PublishTrackProcessingEventsArguments(
Guid TrackId, Guid TrackId,
string Bucket, string Bucket,
string FinalPictureKey, string? FinalPictureKey,
string FinalAudioKey, string FinalAudioKey,
string AudioDestinationFolderKey, string AudioDestinationFolderKey,
ImageSizes Sizes); ImageSizes Sizes);
@@ -11,11 +11,21 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{ {
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
{ {
var builder = RoutingSlips.Create(correlationId) var builder = RoutingSlips.Create(correlationId);
.AddStep(
var hasPicture = message.PictureIntentId != null
&& message.PictureSourceKey != null
&& message.PictureDestinationKey != null;
if (hasPicture)
{
builder.AddStep(
ActivityNames.CopyPictureToFinal, ActivityNames.CopyPictureToFinal,
CopyFileInBucketActivity.ExecuteEndpointName, CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey!, message.Bucket, message.PictureDestinationKey!));
}
builder
.AddStep( .AddStep(
ActivityNames.CopyAudioToFinal, ActivityNames.CopyAudioToFinal,
CopyFileInBucketActivity.ExecuteEndpointName, CopyFileInBucketActivity.ExecuteEndpointName,
@@ -23,21 +33,23 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
.AddStep( .AddStep(
ActivityNames.ConsumeUploadIntents, ActivityNames.ConsumeUploadIntents,
ConsumeUploadIntentsActivity.ExecuteEndpointName, ConsumeUploadIntentsActivity.ExecuteEndpointName,
new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) new ConsumeUploadIntentsArguments(
hasPicture ? [message.PictureIntentId!.Value, message.AudioIntentId] : [message.AudioIntentId]))
.AddStep( .AddStep(
ActivityNames.PublishTrackProcessingEvents, ActivityNames.PublishTrackProcessingEvents,
PublishTrackProcessingEventsActivity.ExecuteEndpointName, PublishTrackProcessingEventsActivity.ExecuteEndpointName,
new PublishTrackProcessingEventsArguments( new PublishTrackProcessingEventsArguments(
message.TrackId, message.TrackId,
message.Bucket, message.Bucket,
message.PictureDestinationKey, hasPicture ? message.PictureDestinationKey : null,
message.AudioDestinationKey, message.AudioDestinationKey,
message.AudioProcessedFolderKey, message.AudioProcessedFolderKey,
message.Sizes)) message.Sizes))
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket);
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); if (hasPicture)
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey!);
builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey);
return builder; return builder;
@@ -47,7 +47,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
} }
if (track.Pictures.IsProcessed) if (track.Pictures.IsProcessed && !string.IsNullOrEmpty(track.Pictures.OriginalName))
{ {
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName));
@@ -48,6 +48,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
context.Saga.UpdatedAt = DateTime.UtcNow; context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket; context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey; context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.PictureProcessed = context.Message.PictureDestinationKey == null;
context.Saga.AudioKey = context.Message.AudioDestinationKey; context.Saga.AudioKey = context.Message.AudioDestinationKey;
}) })
.TransitionTo(Processing)); .TransitionTo(Processing));
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class TrackPublicationAndAlbumType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsPublished",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("UPDATE \"Tracks\" SET \"IsPublished\" = TRUE;");
migrationBuilder.AddColumn<int>(
name: "Type",
table: "Albums",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsPublished",
table: "Tracks");
migrationBuilder.DropColumn(
name: "Type",
table: "Albums");
}
}
}
@@ -224,6 +224,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -435,6 +438,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<bool>("IsExplicit") b.Property<bool>("IsExplicit")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus") b.Property<int>("LifeCycleStatus")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -1,8 +1,12 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.Albums namespace Musify.Api.DataTransferObjects.Albums
{ {
public record CreateAlbumRequest( public record CreateAlbumRequest(
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear, int? ReleaseYear,
Guid PictureIntentId); Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null);
} }
@@ -1,7 +1,10 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.Albums namespace Musify.Api.DataTransferObjects.Albums
{ {
public record UpdateAlbumRequest( public record UpdateAlbumRequest(
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear); int? ReleaseYear,
AlbumType? Type = null);
} }
@@ -4,7 +4,7 @@ namespace Musify.Api.DataTransferObjects.Tracks
{ {
public record CreateTrackRequest( public record CreateTrackRequest(
string Title, string Title,
Guid PictureIntentId, Guid? PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false); bool IsExplicit = false);
@@ -1,10 +1,16 @@
namespace Musify.Api.DataTransferObjects.Tracks namespace Musify.Api.DataTransferObjects.Tracks
{ {
public record AudioUploadRequest(
string FileType,
string ContentType,
long? ExpectedSizeBytes = null);
public record RequestTrackUploadUrlsRequest( public record RequestTrackUploadUrlsRequest(
string PictureFileType, string? PictureFileType = null,
string PictureContentType, string? PictureContentType = null,
string AudioFileType, string? AudioFileType = null,
string AudioContentType, string? AudioContentType = null,
long? ExpectedPictureSizeBytes = null, long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null); long? ExpectedAudioSizeBytes = null,
IReadOnlyList<AudioUploadRequest>? Audios = null);
} }
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record SetTrackPublishedRequest(bool IsPublished);
}
@@ -0,0 +1,4 @@
namespace Musify.Api.DataTransferObjects.Tracks
{
public record SetTracksPublishedRequest(IReadOnlyList<Guid> TrackIds, bool IsPublished);
}
@@ -84,12 +84,13 @@ namespace Musify.Api.Endpoints
group.MapPost("/", CreateAlbum) group.MapPost("/", CreateAlbum)
.WithName("CreateAlbum") .WithName("CreateAlbum")
.WithSummary("Create a new album") .WithSummary("Create a new album, optionally with some of your tracks already in it")
.AddEndpointFilter<ValidationFilter<CreateAlbumRequest>>() .AddEndpointFilter<ValidationFilter<CreateAlbumRequest>>()
.RequireAuthorization() .RequireAuthorization()
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created) .Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem() .ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapPut("/{id:guid}", UpdateAlbum) group.MapPut("/{id:guid}", UpdateAlbum)
@@ -245,7 +246,8 @@ namespace Musify.Api.Endpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), new CreateAlbumCommand(
currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId, request.Type, request.TrackIds),
cancellationToken); cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}"); return result.ToCreatedResult(album => $"/albums/{album.Id}");
@@ -259,7 +261,7 @@ namespace Musify.Api.Endpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear), new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.Type),
cancellationToken); cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -59,7 +59,7 @@ namespace Musify.Api.Endpoints
group.MapGet("/users/{userId:long}", GetTracksByUserId) group.MapGet("/users/{userId:long}", GetTracksByUserId)
.AddEndpointFilter<ValidationFilter<PageQuery>>() .AddEndpointFilter<ValidationFilter<PageQuery>>()
.WithName("GetTracksByUserId") .WithName("GetTracksByUserId")
.WithSummary("Get paginated tracks for a user, optionally filtered by name and audio processing status. Each track lists the albums it belongs to") .WithSummary("Get paginated tracks for a user, optionally filtered by name, audio processing status and published state. Each track lists the albums it belongs to. Only the owner sees unpublished tracks")
.Produces<PaginatedResponse<UserTrackApplicationResponse>>(); .Produces<PaginatedResponse<UserTrackApplicationResponse>>();
group.MapGet("/users/{userId:long}/status-counts", GetTrackStatusCounts) group.MapGet("/users/{userId:long}/status-counts", GetTrackStatusCounts)
@@ -72,7 +72,7 @@ namespace Musify.Api.Endpoints
group.MapPost("/", CreateTrack) group.MapPost("/", CreateTrack)
.WithName("CreateTrack") .WithName("CreateTrack")
.WithSummary("Create a new track") .WithSummary("Create a new track, unpublished, from upload intents. The picture is optional")
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>() .AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
.RequireAuthorization() .RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created) .Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
@@ -101,9 +101,29 @@ namespace Musify.Api.Endpoints
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden); .Produces(StatusCodes.Status403Forbidden);
group.MapPut("/{id:guid}/published", SetTrackPublished)
.WithName("SetTrackPublished")
.WithSummary("Publish or unpublish one of your tracks. Only tracks with completed audio processing can be published")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/published", SetTracksPublished)
.WithName("SetTracksPublished")
.WithSummary("Publish or unpublish several of your tracks at once. Missing tracks and tracks that cannot be published yet are returned as skipped")
.AddEndpointFilter<ValidationFilter<SetTracksPublishedRequest>>()
.RequireAuthorization()
.Produces<SetTracksPublishedResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/upload-urls", RequestTrackUploadUrls) group.MapPost("/upload-urls", RequestTrackUploadUrls)
.WithName("RequestTrackUploadUrls") .WithName("RequestTrackUploadUrls")
.WithSummary("Request pre-signed URLs to upload track picture and audio") .WithSummary("Request pre-signed URLs to upload the audio of one or many tracks and an optional picture shared by all of them")
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>() .AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
.RequireAuthorization() .RequireAuthorization()
.Produces<TrackUploadUrlsResponse>() .Produces<TrackUploadUrlsResponse>()
@@ -128,10 +148,11 @@ namespace Musify.Api.Endpoints
private static async Task<IResult> GetTrackById( private static async Task<IResult> GetTrackById(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); var result = await mediator.Send(new GetTrackByIdQuery(id, currentUser.Id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -171,14 +192,39 @@ namespace Musify.Api.Endpoints
private static async Task<IResult> GetTracksByUserId( private static async Task<IResult> GetTracksByUserId(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
long userId, long userId,
string? name, string? name,
[AsParameters] PageQuery page, [AsParameters] PageQuery page,
CancellationToken cancellationToken, CancellationToken cancellationToken,
ProcessingStatus[]? status = null) ProcessingStatus[]? status = null,
bool? published = null)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status), new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status, currentUser.Id, published),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetTrackPublished(
IMediator mediator,
CurrentUser currentUser,
Guid id,
SetTrackPublishedRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new SetTrackPublishedCommand(currentUser.RequiredId, id, request.IsPublished), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetTracksPublished(
IMediator mediator,
CurrentUser currentUser,
SetTracksPublishedRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new SetTracksPublishedCommand(currentUser.RequiredId, request.TrackIds, request.IsPublished),
cancellationToken); cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -243,7 +289,8 @@ namespace Musify.Api.Endpoints
request.AudioFileType, request.AudioFileType,
request.AudioContentType, request.AudioContentType,
request.ExpectedPictureSizeBytes, request.ExpectedPictureSizeBytes,
request.ExpectedAudioSizeBytes), request.ExpectedAudioSizeBytes,
request.Audios?.Select(audio => new AudioUploadSpec(audio.FileType, audio.ContentType, audio.ExpectedSizeBytes)).ToList()),
cancellationToken); cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -5,6 +5,8 @@ namespace Musify.Api.Validators.Albums
{ {
public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumRequest> public sealed class CreateAlbumRequestValidator : AbstractValidator<CreateAlbumRequest>
{ {
public const int MaxTracksOnCreate = 500;
public CreateAlbumRequestValidator() public CreateAlbumRequestValidator()
{ {
RuleFor(x => x.Title) RuleFor(x => x.Title)
@@ -20,6 +22,18 @@ namespace Musify.Api.Validators.Albums
RuleFor(x => x.PictureIntentId) RuleFor(x => x.PictureIntentId)
.NotEmpty(); .NotEmpty();
RuleFor(x => x.Type)
.IsInEnum();
RuleFor(x => x.TrackIds)
.Must(ids => ids!.Count <= MaxTracksOnCreate)
.WithMessage($"At most {MaxTracksOnCreate} tracks can be added at once.")
.When(x => x.TrackIds != null);
RuleForEach(x => x.TrackIds)
.NotEmpty()
.When(x => x.TrackIds != null);
} }
} }
} }
@@ -17,6 +17,10 @@ namespace Musify.Api.Validators.Albums
RuleFor(x => x.ReleaseYear) RuleFor(x => x.ReleaseYear)
.InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear)
.When(x => x.ReleaseYear.HasValue); .When(x => x.ReleaseYear.HasValue);
RuleFor(x => x.Type)
.IsInEnum()
.When(x => x.Type.HasValue);
} }
} }
} }
@@ -13,7 +13,8 @@ namespace Musify.Api.Validators.Tracks
.MaximumLength(Limits.TrackTitle); .MaximumLength(Limits.TrackTitle);
RuleFor(x => x.PictureIntentId) RuleFor(x => x.PictureIntentId)
.NotEmpty(); .NotEqual(Guid.Empty)
.When(x => x.PictureIntentId.HasValue);
RuleFor(x => x.AudioIntentId) RuleFor(x => x.AudioIntentId)
.NotEmpty(); .NotEmpty();
@@ -6,25 +6,53 @@ namespace Musify.Api.Validators.Tracks
{ {
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest> public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
{ {
public const int MaxAudiosPerRequest = 20;
public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration)
{ {
RuleFor(x => x.PictureFileType) When(x => x.PictureFileType != null || x.PictureContentType != null, () =>
{
RuleFor(x => x.PictureFileType!)
.MustBeFileType(Uploads.PictureFileTypes); .MustBeFileType(Uploads.PictureFileTypes);
RuleFor(x => x.PictureContentType) RuleFor(x => x.PictureContentType!)
.MustBeContentType(Uploads.PictureContentTypes); .MustBeContentType(Uploads.PictureContentTypes);
RuleFor(x => x.AudioFileType)
.MustBeFileType(Uploads.AudioFileTypes);
RuleFor(x => x.AudioContentType)
.MustBeContentType(Uploads.AudioContentTypes);
RuleFor(x => x.ExpectedPictureSizeBytes) RuleFor(x => x.ExpectedPictureSizeBytes)
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
});
When(x => x.Audios is { Count: > 0 }, () =>
{
RuleFor(x => x.AudioFileType)
.Null()
.WithMessage("Use either the audio fields or the audios list, not both.");
RuleFor(x => x.AudioContentType)
.Null()
.WithMessage("Use either the audio fields or the audios list, not both.");
RuleFor(x => x.Audios!)
.Must(audios => audios.Count <= MaxAudiosPerRequest)
.WithMessage($"At most {MaxAudiosPerRequest} audio files can be requested at once.");
RuleForEach(x => x.Audios!).ChildRules(audio =>
{
audio.RuleFor(a => a.FileType).MustBeFileType(Uploads.AudioFileTypes);
audio.RuleFor(a => a.ContentType).MustBeContentType(Uploads.AudioContentTypes);
audio.RuleFor(a => a.ExpectedSizeBytes).MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
});
}).Otherwise(() =>
{
RuleFor(x => x.AudioFileType!)
.MustBeFileType(Uploads.AudioFileTypes);
RuleFor(x => x.AudioContentType!)
.MustBeContentType(Uploads.AudioContentTypes);
RuleFor(x => x.ExpectedAudioSizeBytes) RuleFor(x => x.ExpectedAudioSizeBytes)
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
});
} }
} }
} }
@@ -0,0 +1,21 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks
{
public sealed class SetTracksPublishedRequestValidator : AbstractValidator<SetTracksPublishedRequest>
{
public const int MaxTracksPerRequest = 100;
public SetTracksPublishedRequestValidator()
{
RuleFor(x => x.TrackIds)
.NotEmpty()
.Must(ids => ids.Count <= MaxTracksPerRequest)
.WithMessage($"At most {MaxTracksPerRequest} tracks can be updated at once.");
RuleForEach(x => x.TrackIds)
.NotEmpty();
}
}
}
+2 -2
View File
@@ -118,8 +118,8 @@
}, },
"UploadIntent": { "UploadIntent": {
"UploadUrlExpiresInSeconds": 120, "UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5, "MaxActiveUploadIntentsPerUser": 50,
"MaxActiveUploadBytesPerUser": 209715200, "MaxActiveUploadBytesPerUser": 1073741824,
"MaxUploadBytes": 104857600, "MaxUploadBytes": 104857600,
"DefaultExpectedPictureSizeBytes": 10485760, "DefaultExpectedPictureSizeBytes": 10485760,
"DefaultExpectedAudioSizeBytes": 104857600, "DefaultExpectedAudioSizeBytes": 104857600,
+2 -2
View File
@@ -114,8 +114,8 @@
}, },
"UploadIntent": { "UploadIntent": {
"UploadUrlExpiresInSeconds": 120, "UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5, "MaxActiveUploadIntentsPerUser": 50,
"MaxActiveUploadBytesPerUser": 209715200, "MaxActiveUploadBytesPerUser": 1073741824,
"MaxUploadBytes": 104857600, "MaxUploadBytes": 104857600,
"DefaultExpectedPictureSizeBytes": 10485760, "DefaultExpectedPictureSizeBytes": 10485760,
"DefaultExpectedAudioSizeBytes": 104857600, "DefaultExpectedAudioSizeBytes": 104857600,
@@ -1,5 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Musify.Api.DataTransferObjects; using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.Albums; using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Tests.TestSupport; using Musify.Api.Tests.TestSupport;
@@ -14,6 +16,11 @@ namespace Musify.Api.Tests.Endpoints
[Collection(ApiCollection.Name)] [Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture) public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private Task<long> CreateUserAsync() => fixture.SeedUserAsync(); private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
private async Task<Guid> CreatePictureIntentAsync(HttpClient client) private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
@@ -54,7 +61,7 @@ namespace Musify.Api.Tests.Endpoints
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal("My Album", body.Title); Assert.Equal("My Album", body.Title);
Assert.Equal(userId, body.OwnerUserId); Assert.Equal(userId, body.OwnerUserId);
@@ -89,7 +96,7 @@ namespace Musify.Api.Tests.Endpoints
var client = fixture.CreateAuthenticatedClient(userId); var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client); var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -104,7 +111,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner); var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient); var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
@@ -119,7 +126,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner); var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient); var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.NewGuid()), TestContext.Current.CancellationToken); $"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.NewGuid()), TestContext.Current.CancellationToken);
@@ -134,7 +141,7 @@ namespace Musify.Api.Tests.Endpoints
var ownerClient = fixture.CreateAuthenticatedClient(owner); var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient); var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var response = await ownerClient.PutAsJsonAsync( var response = await ownerClient.PutAsJsonAsync(
$"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.Empty), TestContext.Current.CancellationToken); $"/albums/{created!.Id}/picture", new UpdateAlbumPictureRequest(Guid.Empty), TestContext.Current.CancellationToken);
@@ -149,7 +156,7 @@ namespace Musify.Api.Tests.Endpoints
var client = fixture.CreateAuthenticatedClient(userId); var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client); var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
@@ -34,6 +34,7 @@ namespace Musify.Api.Tests.Endpoints
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner, Owner = owner,
IsPublished = true,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
}; };
foreach (var tag in track.Tags) foreach (var tag in track.Tags)
@@ -19,7 +19,8 @@ namespace Musify.Api.Tests.Endpoints
private async Task<Guid> SeedTrackAsync( private async Task<Guid> SeedTrackAsync(
long ownerId, long ownerId,
string title, string title,
ProcessingStatus status = ProcessingStatus.Completed) ProcessingStatus status = ProcessingStatus.Completed,
bool isPublished = true)
{ {
using var scope = fixture.Services.CreateScope(); using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>(); var database = scope.ServiceProvider.GetRequiredService<Database>();
@@ -28,6 +29,7 @@ namespace Musify.Api.Tests.Endpoints
Title = title, Title = title,
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = ownerId, OwnerUserId = ownerId,
IsPublished = isPublished,
Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null } Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null }
}; };
database.Tracks.Add(track); database.Tracks.Add(track);
@@ -0,0 +1,298 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.DataTransferObjects;
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class TrackPublicationEndpointsTests(ApiTestFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<Guid> SeedTrackAsync(
long ownerId,
string title,
ProcessingStatus status = ProcessingStatus.Completed,
bool isPublished = true)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = ownerId,
IsPublished = isPublished,
Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null }
};
database.Tracks.Add(track);
await database.SaveChangesAsync(Ct);
return track.Id;
}
private async Task<Guid> SeedAlbumWithTracksAsync(long ownerId, params Guid[] trackIds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var album = new Album { Title = "Album", NormalizedTitle = "ALBUM", OwnerUserId = ownerId };
database.Albums.Add(album);
var number = 1;
foreach (var trackId in trackIds)
database.AlbumHasTracks.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = trackId, TrackNumber = number++ });
await database.SaveChangesAsync(Ct);
return album.Id;
}
private void MockStorage()
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
fixture.StorageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
}
private static int Number(JsonElement element) =>
element.ValueKind == JsonValueKind.String
? int.Parse(element.GetString()!, CultureInfo.InvariantCulture)
: element.GetInt32();
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
var text = await response.Content.ReadAsStringAsync(Ct);
return JsonDocument.Parse(text).RootElement.Clone();
}
private static string UserPath(long userId) => $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}";
[Fact]
public async Task PutPublished_Owner_PublishesTheTrackForEveryone()
{
var userId = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(userId, "Hidden", isPublished: false);
var anonymous = fixture.CreateAnonymousClient();
var before = await anonymous.GetAsync($"/tracks/{trackId}", Ct);
var publish = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
var after = await anonymous.GetAsync($"/tracks/{trackId}", Ct);
Assert.Equal(HttpStatusCode.NotFound, before.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, publish.StatusCode);
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
Assert.True((await ReadJsonAsync(after)).GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task PutPublished_AudioNotProcessed_ReturnsBadRequest()
{
var userId = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Processing, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PutPublished_NotTheOwnerOrAnonymous_IsRejected()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var trackId = await SeedTrackAsync(owner, "Mine", isPublished: false);
var asStranger = await fixture.CreateAuthenticatedClient(stranger)
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
var anonymous = await fixture.CreateAnonymousClient()
.PutAsJsonAsync($"/tracks/{trackId}/published", new SetTrackPublishedRequest(true), Ct);
Assert.Equal(HttpStatusCode.Forbidden, asStranger.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task PutPublishedBatch_UpdatesProcessedTracksAndReportsTheSkippedOnes()
{
var userId = await fixture.SeedUserAsync();
var ready = await SeedTrackAsync(userId, "Ready", isPublished: false);
var busy = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([ready, busy], true), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal([ready], body.GetProperty("updatedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
Assert.Equal([busy], body.GetProperty("skippedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
}
[Fact]
public async Task PutPublishedBatch_EmptyList_ReturnsBadRequest()
{
var userId = await fixture.SeedUserAsync();
var response = await fixture.CreateAuthenticatedClient(userId)
.PutAsJsonAsync("/tracks/published", new SetTracksPublishedRequest([], true), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetUserTracks_UnpublishedTracksOnlyReachTheOwner()
{
var userId = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "Live");
await SeedTrackAsync(userId, "Hidden", isPublished: false);
var path = UserPath(userId);
var anonymous = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync(path, Ct));
var asStranger = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(stranger).GetAsync(path, Ct));
var asOwner = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync(path, Ct));
var onlyHidden = await ReadJsonAsync(await fixture.CreateAuthenticatedClient(userId).GetAsync($"{path}?published=false", Ct));
Assert.Equal(1, Number(anonymous.GetProperty("totalItemCount")));
Assert.Equal(1, Number(asStranger.GetProperty("totalItemCount")));
Assert.Equal(2, Number(asOwner.GetProperty("totalItemCount")));
Assert.Equal("Hidden", onlyHidden.GetProperty("items")[0].GetProperty("title").GetString());
Assert.False(onlyHidden.GetProperty("items")[0].GetProperty("isPublished").GetBoolean());
}
[Fact]
public async Task StatusCounts_SplitPublishedFromUnpublished()
{
var userId = await fixture.SeedUserAsync();
await SeedTrackAsync(userId, "Live");
await SeedTrackAsync(userId, "Hidden", isPublished: false);
await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending, isPublished: false);
var response = await fixture.CreateAuthenticatedClient(userId).GetAsync($"{UserPath(userId)}/status-counts", Ct);
var body = await ReadJsonAsync(response);
Assert.Equal(1, Number(body.GetProperty("published")));
Assert.Equal(2, Number(body.GetProperty("unpublished")));
}
[Fact]
public async Task Search_AndStream_IgnoreUnpublishedTracks()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var title = $"Secret{Guid.NewGuid():N}";
var trackId = await SeedTrackAsync(owner, title, isPublished: false);
var search = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/tracks?name={title}", Ct));
var stream = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/tracks/{trackId}/stream", Ct);
Assert.Equal(0, Number(search.GetProperty("totalItemCount")));
Assert.Equal(HttpStatusCode.NotFound, stream.StatusCode);
}
[Fact]
public async Task GetAlbum_HidesUnpublishedTracksFromTheListAndTheCount()
{
var userId = await fixture.SeedUserAsync();
var live = await SeedTrackAsync(userId, "Live");
var hidden = await SeedTrackAsync(userId, "Hidden", isPublished: false);
var albumId = await SeedAlbumWithTracksAsync(userId, hidden, live);
var tracks = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}/tracks", Ct));
var album = await ReadJsonAsync(await fixture.CreateAnonymousClient().GetAsync($"/albums/{albumId}", Ct));
Assert.Equal(1, Number(tracks.GetProperty("totalItemCount")));
Assert.Equal(1, Number(album.GetProperty("trackCount")));
Assert.Equal("Album", album.GetProperty("type").GetString());
}
[Fact]
public async Task PostUploadUrls_ManyAudiosAndSharedPicture_ReturnsAnIntentPerFile()
{
var userId = await fixture.SeedUserAsync();
MockStorage();
var request = new RequestTrackUploadUrlsRequest(
"webp", "image/webp", Audios: [new("mp3", "audio/mpeg", 1000), new("flac", "audio/flac", 2000)]);
var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync("/tracks/upload-urls", request, Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
var audios = body.GetProperty("audios").EnumerateArray().ToList();
Assert.Equal(2, audios.Count);
Assert.All(audios, audio => Assert.NotEqual(JsonValueKind.Null, audio.GetProperty("pictureIntentId").ValueKind));
Assert.Equal(2, audios.Select(audio => audio.GetProperty("intentId").GetGuid()).Distinct().Count());
Assert.Equal(2, audios.Select(audio => audio.GetProperty("pictureIntentId").GetGuid()).Distinct().Count());
}
[Fact]
public async Task PostUploadUrls_AudiosOnly_HasNoPictureIntent()
{
var userId = await fixture.SeedUserAsync();
MockStorage();
var response = await fixture.CreateAuthenticatedClient(userId).PostAsJsonAsync(
"/tracks/upload-urls", new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response);
Assert.Equal(JsonValueKind.Null, body.GetProperty("pictureIntentId").ValueKind);
Assert.Equal(JsonValueKind.Null, body.GetProperty("audios")[0].GetProperty("pictureIntentId").ValueKind);
}
[Fact]
public async Task PostAlbum_WithTypeAndTracks_LinksThemAndPutChangesTheType()
{
var userId = await fixture.SeedUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var first = await SeedTrackAsync(userId, "First");
var second = await SeedTrackAsync(userId, "Second");
MockStorage();
var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct));
var intentId = upload.GetProperty("intentId").GetGuid();
var created = await client.PostAsJsonAsync(
"/albums", new CreateAlbumRequest("Typed", null, 2024, intentId, AlbumType.EP, [second, first]), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var album = await ReadJsonAsync(created);
Assert.Equal("EP", album.GetProperty("type").GetString());
Assert.Equal(2, Number(album.GetProperty("trackCount")));
var albumId = album.GetProperty("id").GetGuid();
var ids = await ReadJsonAsync(await client.GetAsync($"/albums/{albumId}/tracks/ids", Ct));
Assert.Equal([second, first], ids.GetProperty("trackIds").EnumerateArray().Select(e => e.GetGuid()).ToList());
var updated = await client.PutAsJsonAsync($"/albums/{albumId}", new UpdateAlbumRequest("Typed", null, 2024, AlbumType.Single), Ct);
Assert.Equal("Single", (await ReadJsonAsync(updated)).GetProperty("type").GetString());
}
[Fact]
public async Task PostAlbum_WithTrackOfAnotherUser_ReturnsForbidden()
{
var userId = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var theirs = await SeedTrackAsync(stranger, "Theirs");
var client = fixture.CreateAuthenticatedClient(userId);
MockStorage();
var upload = await ReadJsonAsync(await client.PostAsJsonAsync("/albums/upload-picture", new PictureUploadRequest("webp", "image/webp"), Ct));
var response = await client.PostAsJsonAsync(
"/albums", new CreateAlbumRequest("Stolen", null, null, upload.GetProperty("intentId").GetGuid(), TrackIds: [theirs]), Ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
}
@@ -31,7 +31,8 @@ namespace Musify.Api.Tests.Endpoints
Title = trackTitle, Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(), NormalizedTitle = trackTitle.ToUpperInvariant(),
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner Owner = owner,
IsPublished = true
}; };
database.Tracks.Add(track); database.Tracks.Add(track);
database.ListeningHistories.Add(new ListeningHistory database.ListeningHistories.Add(new ListeningHistory
@@ -0,0 +1,122 @@
using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Validators.Albums;
using Musify.Api.Validators.Tracks;
using Musify.Application.Configuration;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Api.Tests.Validators
{
public sealed class LibraryRequestValidatorTests
{
private readonly UploadIntentConfiguration _configuration = new();
[Fact]
public void CreateTrack_WithoutPicture_Passes()
{
var request = new CreateTrackRequest("Song", null, Guid.NewGuid(), [Genre.Pop]);
Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void CreateTrack_EmptyPictureIntent_Fails()
{
var request = new CreateTrackRequest("Song", Guid.Empty, Guid.NewGuid(), [Genre.Pop]);
Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void UploadUrls_ManyAudiosWithSharedPicture_Passes()
{
var request = new RequestTrackUploadUrlsRequest(
"png", "image/png", Audios: [new("mp3", "audio/mpeg", 10), new("flac", "audio/flac")]);
Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_AudiosWithoutPicture_Passes()
{
var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg")]);
Assert.True(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_NoAudioAtAll_Fails()
{
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest("png", "image/png")).IsValid);
}
[Fact]
public void UploadUrls_LegacyAudioTogetherWithAudiosList_Fails()
{
var request = new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", Audios: [new("mp3", "audio/mpeg")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_HalfAPicture_Fails()
{
var request = new RequestTrackUploadUrlsRequest("png", null, Audios: [new("mp3", "audio/mpeg")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_InvalidAudioInTheList_Fails()
{
var request = new RequestTrackUploadUrlsRequest(Audios: [new("mp3", "audio/mpeg"), new("png", "image/png")]);
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(request).IsValid);
}
[Fact]
public void UploadUrls_TooManyAudios_Fails()
{
var audios = Enumerable.Range(0, RequestTrackUploadUrlsRequestValidator.MaxAudiosPerRequest + 1)
.Select(_ => new AudioUploadRequest("mp3", "audio/mpeg"))
.ToList();
Assert.False(new RequestTrackUploadUrlsRequestValidator(_configuration).Validate(new RequestTrackUploadUrlsRequest(Audios: audios)).IsValid);
}
[Fact]
public void SetTracksPublished_RequiresBetweenOneAndTheMaximumTracks()
{
var validator = new SetTracksPublishedRequestValidator();
var tooMany = Enumerable.Range(0, SetTracksPublishedRequestValidator.MaxTracksPerRequest + 1).Select(_ => Guid.NewGuid()).ToList();
Assert.True(validator.Validate(new SetTracksPublishedRequest([Guid.NewGuid()], true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest([], true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest(tooMany, true)).IsValid);
Assert.False(validator.Validate(new SetTracksPublishedRequest([Guid.Empty], true)).IsValid);
}
[Fact]
public void CreateAlbum_UnknownTypeOrTooManyTracks_Fails()
{
var validator = new CreateAlbumRequestValidator();
var tooMany = Enumerable.Range(0, CreateAlbumRequestValidator.MaxTracksOnCreate + 1).Select(_ => Guid.NewGuid()).ToList();
Assert.True(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), AlbumType.EP, [Guid.NewGuid()])).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), (AlbumType)42)).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: tooMany)).IsValid);
Assert.False(validator.Validate(new CreateAlbumRequest("Album", null, null, Guid.NewGuid(), TrackIds: [Guid.Empty])).IsValid);
}
[Fact]
public void UpdateAlbum_TypeIsOptionalButMustBeKnown()
{
var validator = new UpdateAlbumRequestValidator();
Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null)).IsValid);
Assert.True(validator.Validate(new UpdateAlbumRequest("Album", null, null, AlbumType.Single)).IsValid);
Assert.False(validator.Validate(new UpdateAlbumRequest("Album", null, null, (AlbumType)42)).IsValid);
}
}
}
@@ -0,0 +1,142 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums
{
public sealed class CreateAlbumWithTracksAndTypeTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private CreateAlbumCommandHandler CreateHandler()
{
_storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
return new CreateAlbumCommandHandler(
_eventBus,
Database,
new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Album());
}
private static UploadIntent PictureIntent(long userId) =>
TestEntities.UploadIntent(userId, UploadIntentPurpose.AlbumPicture, objectName: "cover.webp");
[Theory]
[InlineData(AlbumType.Album)]
[InlineData(AlbumType.EP)]
[InlineData(AlbumType.Single)]
public async Task Handle_StoresAndReturnsTheAlbumType(AlbumType type)
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Typed", null, null, intent.Id, type), Ct);
Assert.False(result.IsError);
Assert.Equal(type, result.Value.Type);
Assert.Equal(type, (await Database.Albums.AsNoTracking().SingleAsync(Ct)).Type);
}
[Fact]
public async Task Handle_DefaultsToAlbumType()
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(new CreateAlbumCommand(owner.Id, "Default", null, null, intent.Id), Ct);
Assert.Equal(AlbumType.Album, result.Value.Type);
}
[Fact]
public async Task Handle_WithTrackIds_CreatesAlbumAndLinksTracksInOrderInOneSave()
{
var owner = TestEntities.User();
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second", isPublished: false);
var third = TestEntities.Track(owner, "Third");
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, first, second, third, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "With tracks", null, null, intent.Id, AlbumType.EP, [third.Id, first.Id, second.Id, third.Id]), Ct);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TrackCount);
Assert.Equal([third.Id, first.Id], result.Value.CoverTrackIds);
var links = await Database.AlbumHasTracks.AsNoTracking()
.Where(link => link.AlbumId == result.Value.Id)
.OrderBy(link => link.TrackNumber)
.ToListAsync(Ct);
Assert.Equal([third.Id, first.Id, second.Id], links.Select(link => link.TrackId));
Assert.Equal([1, 2, 3], links.Select(link => link.TrackNumber));
await _eventBus.Received(1).PublishAsync(Arg.Any<CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_TrackOfAnotherUser_ReturnsForbiddenAndCreatesNothing()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var theirs = TestEntities.Track(stranger, "Theirs");
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, stranger, theirs, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "Stolen", null, null, intent.Id, TrackIds: [theirs.Id]), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.Empty(await Database.Albums.ToListAsync(Ct));
Assert.Empty(await Database.AlbumHasTracks.ToListAsync(Ct));
await _eventBus.DidNotReceive().PublishAsync(Arg.Any<CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_MissingTrack_ReturnsNotFoundAndCreatesNothing()
{
var owner = TestEntities.User();
var intent = PictureIntent(owner.Id);
await SeedAsync(owner, intent);
var result = await CreateHandler().Handle(
new CreateAlbumCommand(owner.Id, "Ghost", null, null, intent.Id, TrackIds: [Guid.NewGuid()]), Ct);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
Assert.Empty(await Database.Albums.ToListAsync(Ct));
}
[Fact]
public async Task Update_ChangesTypeOnlyWhenProvided()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
album.Type = AlbumType.EP;
await SeedAsync(owner, album);
var handler = new UpdateAlbumCommandHandler(Database, NoOpLogger<UpdateAlbumCommandHandler>());
var untouched = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024), Ct);
var changed = await handler.Handle(new UpdateAlbumCommand(owner.Id, album.Id, "Renamed", null, 2024, AlbumType.Single), Ct);
Assert.Equal(AlbumType.EP, untouched.Value.Type);
Assert.Equal(AlbumType.Single, changed.Value.Type);
}
}
}
@@ -57,7 +57,8 @@ namespace Musify.Application.Tests.TestSupport
TrackAudio? audio = null, TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null, IEnumerable<Genre>? tags = null,
bool isExplicit = false) bool isExplicit = false,
bool isPublished = true)
{ {
var track = new Track var track = new Track
{ {
@@ -66,6 +67,7 @@ namespace Musify.Application.Tests.TestSupport
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus, LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit, IsExplicit = isExplicit,
IsPublished = isPublished,
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner, Owner = owner,
Pictures = pictures ?? ProcessedPictures(), Pictures = pictures ?? ProcessedPictures(),
@@ -11,9 +11,9 @@ namespace Musify.Application.Tests.Tracks
{ {
public sealed class DeleteTracksCommandHandlerTests : HandlerTestBase public sealed class DeleteTracksCommandHandlerTests : HandlerTestBase
{ {
private readonly IEventBus eventBus = Substitute.For<IEventBus>(); private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private DeleteTracksCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTracksCommandHandler>()); private DeleteTracksCommandHandler CreateHandler() => new(Database, _eventBus, NoOpLogger<DeleteTracksCommandHandler>());
[Fact] [Fact]
public async Task Handle_OwnTracks_MarksAllAsRemovingAndPublishesOneEventEach() public async Task Handle_OwnTracks_MarksAllAsRemovingAndPublishesOneEventEach()
@@ -29,7 +29,7 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(2, result.Value.DeletedTrackIds.Count); Assert.Equal(2, result.Value.DeletedTrackIds.Count);
Assert.Empty(result.Value.FailedTrackIds); Assert.Empty(result.Value.FailedTrackIds);
Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Removing, track.LifeCycleStatus)); Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Removing, track.LifeCycleStatus));
await eventBus.Received(2).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>()); await _eventBus.Received(2).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -45,7 +45,7 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Active, track.LifeCycleStatus)); Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Active, track.LifeCycleStatus));
await eventBus.DidNotReceive().PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>()); await _eventBus.DidNotReceive().PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -75,7 +75,7 @@ namespace Musify.Application.Tests.Tracks
var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [track.Id, track.Id]), TestContext.Current.CancellationToken); var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [track.Id, track.Id]), TestContext.Current.CancellationToken);
Assert.Single(result.Value.DeletedTrackIds); Assert.Single(result.Value.DeletedTrackIds);
await eventBus.Received(1).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>()); await _eventBus.Received(1).PublishAsync(Arg.Any<DeleteTrackEvent>(), Arg.Any<CancellationToken>());
} }
} }
} }
@@ -0,0 +1,119 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class RequestTrackUploadUrlsBatchTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private RequestTrackUploadUrlsCommandHandler CreateHandler()
{
_storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(call => $"https://storage.musify.test/{call.ArgAt<string>(1)}");
var config = TestConfigurations.UploadIntent();
return new RequestTrackUploadUrlsCommandHandler(
new UploadIntentIssuer(
Database,
_storageService,
new UploadIntentValidator(Database, _storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Storage(),
TestConfigurations.Track(),
config);
}
private static readonly AudioUploadSpec[] _threeAudios =
[
new("mp3", "audio/mpeg", 3_000_000),
new("flac", "audio/flac", 30_000_000),
new("wav", "audio/wav", 40_000_000)
];
[Fact]
public async Task Handle_ManyAudiosAndSharedPicture_IssuesOneAudioIntentPerFileAndOnePictureIntentPerTrackOnASingleObject()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(
new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", Audios: _threeAudios), Ct);
Assert.False(result.IsError);
Assert.Equal(3, result.Value.Audios.Count);
Assert.All(result.Value.Audios, audio => Assert.NotNull(audio.PictureIntentId));
var intents = await Database.UploadIntents.AsNoTracking().ToListAsync(Ct);
var pictures = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackPicture).ToList();
var audios = intents.Where(intent => intent.Purpose == UploadIntentPurpose.TrackAudio).ToList();
Assert.Equal(3, pictures.Count);
Assert.Equal(3, audios.Count);
Assert.Single(pictures.Select(intent => intent.Key).Distinct());
Assert.Equal(3, pictures.Select(intent => intent.ObjectName).Distinct().Count());
Assert.Equal(3, audios.Select(intent => intent.Key).Distinct().Count());
Assert.Equal(
result.Value.Audios.Select(audio => audio.PictureIntentId!.Value).Order(),
pictures.Select(intent => intent.Id).Order());
Assert.Equal(result.Value.Audios.Select(audio => audio.IntentId).Order(), audios.Select(intent => intent.Id).Order());
Assert.Equal(result.Value.Audios[0].IntentId, result.Value.AudioIntentId);
Assert.Equal(result.Value.Audios[0].PictureIntentId, result.Value.PictureIntentId);
Assert.Equal(pictures[0].Key, result.Value.PictureKey);
}
[Fact]
public async Task Handle_ManyAudiosWithoutPicture_IssuesNoPictureIntents()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(
new RequestTrackUploadUrlsCommand(user.Id, null, null, Audios: _threeAudios), Ct);
Assert.False(result.IsError);
Assert.Null(result.Value.PictureIntentId);
Assert.Null(result.Value.PictureUploadUrl);
Assert.All(result.Value.Audios, audio => Assert.Null(audio.PictureIntentId));
Assert.All(await Database.UploadIntents.ToListAsync(Ct), intent => Assert.Equal(UploadIntentPurpose.TrackAudio, intent.Purpose));
}
[Fact]
public async Task Handle_LegacyAudioFields_StillReturnASingleItem()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"), Ct);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Audios);
Assert.Equal(result.Value.AudioIntentId, item.IntentId);
Assert.Equal(result.Value.PictureIntentId, item.PictureIntentId);
}
[Fact]
public async Task Handle_NoAudio_ReturnsValidationError()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp"), Ct);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(Ct));
}
}
}
@@ -0,0 +1,146 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class SetTrackPublishedCommandHandlerTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private SetTrackPublishedCommandHandler CreateSingleHandler() => new(Database, NoOpLogger<SetTrackPublishedCommandHandler>());
private SetTracksPublishedCommandHandler CreateBatchHandler() => new(Database, NoOpLogger<SetTracksPublishedCommandHandler>());
private async Task<bool> IsPublishedAsync(Guid trackId) =>
(await Database.Tracks.AsNoTracking().SingleAsync(t => t.Id == trackId, Ct)).IsPublished;
[Fact]
public async Task Single_ProcessedTrack_CanBePublishedAndUnpublished()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, isPublished: false);
await SeedAsync(owner, track);
var publish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct);
var published = await IsPublishedAsync(track.Id);
var unpublish = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct);
Assert.False(publish.IsError);
Assert.True(published);
Assert.False(unpublish.IsError);
Assert.False(await IsPublishedAsync(track.Id));
}
[Theory]
[InlineData(ProcessingStatus.Pending)]
[InlineData(ProcessingStatus.Processing)]
[InlineData(ProcessingStatus.Failed)]
public async Task Single_AudioNotCompleted_CannotBePublished(ProcessingStatus status)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: new Domain.ValueObjects.TrackAudio { TranscodeStatus = status }, isPublished: false);
await SeedAsync(owner, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, true), Ct);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_UnprocessedTrackThatIsPublished_CanStillBeUnpublished()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio(), isPublished: true);
await SeedAsync(owner, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, track.Id, false), Ct);
Assert.False(result.IsError);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_NotTheOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.Track(owner, isPublished: false);
await SeedAsync(owner, stranger, track);
var result = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(stranger.Id, track.Id, true), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.False(await IsPublishedAsync(track.Id));
}
[Fact]
public async Task Single_MissingOrRemovingTrack_ReturnsNotFound()
{
var owner = TestEntities.User();
var removing = TestEntities.Track(owner, lifeCycleStatus: LifeCycleStatus.Removing, isPublished: false);
await SeedAsync(owner, removing);
var missing = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, Guid.NewGuid(), true), Ct);
var gone = await CreateSingleHandler().Handle(new SetTrackPublishedCommand(owner.Id, removing.Id, true), Ct);
Assert.Equal(ErrorType.NotFound, missing.FirstError.Type);
Assert.Equal(ErrorType.NotFound, gone.FirstError.Type);
}
[Fact]
public async Task Batch_PublishesProcessedTracksAndSkipsTheRest()
{
var owner = TestEntities.User();
var ready = TestEntities.Track(owner, "Ready", isPublished: false);
var processing = TestEntities.Track(owner, "Processing", audio: TestEntities.PendingAudio(), isPublished: false);
var missing = Guid.NewGuid();
await SeedAsync(owner, ready, processing);
var result = await CreateBatchHandler().Handle(
new SetTracksPublishedCommand(owner.Id, [ready.Id, processing.Id, missing, ready.Id], true), Ct);
Assert.False(result.IsError);
Assert.Equal([ready.Id], result.Value.UpdatedTrackIds);
Assert.Equal([processing.Id, missing], result.Value.SkippedTrackIds);
Assert.True(await IsPublishedAsync(ready.Id));
Assert.False(await IsPublishedAsync(processing.Id));
}
[Fact]
public async Task Batch_Unpublish_AppliesToEveryOwnedTrack()
{
var owner = TestEntities.User();
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second", audio: TestEntities.PendingAudio());
await SeedAsync(owner, first, second);
var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [first.Id, second.Id], false), Ct);
Assert.Equal(2, result.Value.UpdatedTrackIds.Count);
Assert.Empty(result.Value.SkippedTrackIds);
Assert.False(await IsPublishedAsync(first.Id));
Assert.False(await IsPublishedAsync(second.Id));
}
[Fact]
public async Task Batch_TrackOfAnotherUser_ReturnsForbiddenAndChangesNothing()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var mine = TestEntities.Track(owner, "Mine", isPublished: false);
var theirs = TestEntities.Track(stranger, "Theirs", isPublished: false);
await SeedAsync(owner, stranger, mine, theirs);
var result = await CreateBatchHandler().Handle(new SetTracksPublishedCommand(owner.Id, [mine.Id, theirs.Id], true), Ct);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.False(await IsPublishedAsync(mine.Id));
Assert.False(await IsPublishedAsync(theirs.Id));
}
}
}
@@ -0,0 +1,152 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class TrackCreationWithoutPictureTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly IEventBus _eventBus = Substitute.For<IEventBus>();
private readonly IStorageService _storageService = Substitute.For<IStorageService>();
private CreateTrackCommandHandler CreateHandler()
{
_storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("audio/mpeg", 1024));
return new CreateTrackCommandHandler(
Database,
_eventBus,
new UploadIntentValidator(Database, _storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Track());
}
[Fact]
public async Task CreateTrack_WithoutPicture_UsesPresetsAndSkipsPictureProcessing()
{
var user = TestEntities.User();
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3");
await SeedAsync(user, audio);
var routes = TestConfigurations.Track().Routes;
var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "No cover", null, audio.Id, [Genre.Pop]), Ct);
Assert.False(result.IsError);
Assert.False(result.Value.IsPublished);
var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct);
Assert.Null(stored.Pictures.OriginalName);
Assert.Equal(routes.PresetSmallPicture, stored.Pictures.SmallName);
Assert.Equal(routes.PresetMediumPicture, stored.Pictures.MediumName);
Assert.Equal(routes.PresetLargePicture, stored.Pictures.LargeName);
Assert.Equal(ProcessingStatus.Completed, stored.Pictures.ProcessingStatus);
Assert.False(stored.IsPublished);
await _eventBus.Received(1).PublishAsync(
Arg.Is<CreateTrackResourcesEvent>(message =>
message.PictureIntentId == null
&& message.PictureSourceKey == null
&& message.PictureDestinationKey == null
&& message.AudioIntentId == audio.Id),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task CreateTrack_WithPicture_StaysUnpublishedAndKeepsPictureFlow()
{
var user = TestEntities.User();
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3");
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(new CreateTrackCommand(user.Id, "Cover", picture.Id, audio.Id, [Genre.Pop]), Ct);
Assert.False(result.IsError);
Assert.False(result.Value.IsPublished);
var stored = await Database.Tracks.AsNoTracking().SingleAsync(Ct);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
Assert.Equal(ProcessingStatus.Pending, stored.Pictures.ProcessingStatus);
await _eventBus.Received(1).PublishAsync(
Arg.Is<CreateTrackResourcesEvent>(message => message.PictureIntentId == picture.Id && message.PictureSourceKey != null),
Arg.Any<CancellationToken>());
}
private GetCoverQueryHandler CreateCoverHandler() => new(
Database,
TestConfigurations.Storage("covers-bucket"),
TestConfigurations.Track(),
TestConfigurations.Album(),
TestConfigurations.PlayList());
[Fact]
public async Task Cover_TrackWithoutPictureInAnAlbum_UsesTheAlbumCover()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
});
var album = TestEntities.Album(owner.Id, pictures: new EntityPictures
{
OriginalName = "album-original.webp",
SmallName = "album-small.webp",
MediumName = "album-medium.webp",
LargeName = "album-large.webp"
});
await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.False(result.IsError);
Assert.EndsWith("album-small.webp", result.Value.Key);
}
[Fact]
public async Task Cover_TrackWithoutPictureAndAlbum_FallsBackToThePreset()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, pictures: new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
});
await SeedAsync(owner, track);
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.False(result.IsError);
Assert.EndsWith("PresetSmallPicture.webp", result.Value.Key);
}
[Fact]
public async Task Cover_TrackWithOwnPicture_IgnoresTheAlbumCover()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, track, album, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateCoverHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, PictureSize.Small), Ct);
Assert.EndsWith("small.webp", result.Value.Key);
Assert.DoesNotContain("album", result.Value.Key, StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,259 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Contracts;
using Musify.Application.Genres;
using Musify.Application.Likes;
using Musify.Application.Mixes;
using Musify.Application.PlayLists;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class UnpublishedTrackVisibilityTests : HandlerTestBase
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<(User Owner, User Other, Track Live, Track Hidden)> SeedTracksAsync()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var live = TestEntities.Track(owner, "Live");
var hidden = TestEntities.Track(owner, "Hidden", isPublished: false, tags: [Genre.Jazz]);
await SeedAsync(owner, other, live, hidden);
return (owner, other, live, hidden);
}
[Fact]
public async Task Search_HidesUnpublishedTracksEvenFromTheOwner()
{
var (_, _, live, _) = await SeedTracksAsync();
var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20), Ct);
Assert.Equal([live.Id], result.Items.Select(t => t.Id));
}
[Fact]
public async Task Search_ByGenre_HidesUnpublishedTracks()
{
await SeedTracksAsync();
var result = await new GetTracksQueryHandler(Database).Handle(new GetTracksQuery(null, 1, 20, Genre.Jazz), Ct);
Assert.Empty(result.Items);
}
[Fact]
public async Task UserTracks_OwnerSeesUnpublishedAndCanFilterThem()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
var handler = new GetTracksByUserIdQueryHandler(Database);
var all = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id), Ct);
var onlyHidden = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: false), Ct);
var onlyLive = await handler.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: owner.Id, IsPublished: true), Ct);
Assert.Equal(2, all.Value.TotalItemCount);
Assert.Equal([hidden.Id], onlyHidden.Value.Items.Select(t => t.Id));
Assert.False(onlyHidden.Value.Items[0].IsPublished == true);
Assert.Equal([live.Id], onlyLive.Value.Items.Select(t => t.Id));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UserTracks_OtherViewersAndAnonymousOnlySeePublished(bool signedIn)
{
var (owner, other, live, _) = await SeedTracksAsync();
var result = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null, IsPublished: false), Ct);
Assert.Empty(result.Value.Items);
var all = await new GetTracksByUserIdQueryHandler(Database)
.Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 20, ViewerId: signedIn ? other.Id : null), Ct);
Assert.Equal([live.Id], all.Value.Items.Select(t => t.Id));
}
[Fact]
public async Task TrackById_OnlyTheOwnerCanReadAnUnpublishedTrack()
{
var (owner, other, _, hidden) = await SeedTracksAsync();
var handler = new GetTrackByIdQueryHandler(Database);
var asOwner = await handler.Handle(new GetTrackByIdQuery(hidden.Id, owner.Id), Ct);
var asOther = await handler.Handle(new GetTrackByIdQuery(hidden.Id, other.Id), Ct);
var anonymous = await handler.Handle(new GetTrackByIdQuery(hidden.Id), Ct);
Assert.False(asOwner.IsError);
Assert.False(asOwner.Value.IsPublished);
Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type);
Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type);
}
[Fact]
public async Task StartListening_OnlyTheOwnerCanPlayAnUnpublishedTrack()
{
var (owner, other, _, hidden) = await SeedTracksAsync();
var ticketService = Substitute.For<IStreamTicketService>();
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("ticket", 60));
var playback = TestConfigurations.Playback(allowAnonymousListening: true);
var issuer = new TrackStreamIssuer(ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback);
var handler = new StartListeningCommandHandler(Database, issuer, playback, NoOpLogger<StartListeningCommandHandler>());
var asOwner = await handler.Handle(new StartListeningCommand(hidden.Id, owner.Id), Ct);
var asOther = await handler.Handle(new StartListeningCommand(hidden.Id, other.Id), Ct);
var anonymous = await handler.Handle(new StartListeningCommand(hidden.Id, null), Ct);
Assert.False(asOwner.IsError);
Assert.Equal(ErrorType.NotFound, asOther.FirstError.Type);
Assert.Equal(ErrorType.NotFound, anonymous.FirstError.Type);
Assert.Single(await Database.ListeningHistories.ToListAsync(Ct));
}
[Fact]
public async Task Genres_CountOnlyPublishedTracks()
{
await SeedTracksAsync();
var genres = await new GetGenresQueryHandler(Database).Handle(new GetGenresQuery(), Ct);
var pop = Assert.Single(genres);
Assert.Equal(Genre.Pop, pop.Genre);
Assert.Equal(1, pop.TrackCount);
}
[Fact]
public async Task ListeningHistory_AndLastListened_SkipUnpublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
await SeedAsync(
TestEntities.ListeningHistory(owner.Id, live.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, hidden.Id, DateTime.UtcNow));
var history = await new GetListeningHistoryQueryHandler(Database).Handle(new GetListeningHistoryQuery(owner.Id), Ct);
var last = await new GetLastTrackListenedByUserIdQueryHandler(Database).Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), Ct);
Assert.Equal([live.Id], history.Select(t => t.Id));
Assert.Equal(live.Id, last?.Id);
}
[Fact]
public async Task Likes_UnpublishedTracksCannotBeLikedAndDisappearFromTheLikedList()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var toggle = new ToggleTrackLikeCommandHandler(Database, NoOpLogger<ToggleTrackLikeCommandHandler>());
var likeHidden = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, hidden.Id), Ct);
var likeLive = await toggle.Handle(new ToggleTrackLikeCommand(other.Id, live.Id), Ct);
await SeedAsync(new TrackLike { UserId = owner.Id, TrackId = hidden.Id });
var likedByOwner = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(owner.Id, 1, 20), Ct);
var likedByOther = await new GetLikedTracksQueryHandler(Database).Handle(new GetLikedTracksQuery(other.Id, 1, 20), Ct);
Assert.Equal(ErrorType.NotFound, likeHidden.FirstError.Type);
Assert.False(likeLive.IsError);
Assert.Empty(likedByOwner.Items);
Assert.Equal([live.Id], likedByOther.Items.Select(t => t.Id));
}
[Fact]
public async Task Mixes_OnlyUseAndShowPublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
await new GenerateMixesForUserCommandHandler(Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>())
.Handle(new GenerateMixesForUserCommand(owner.Id), Ct);
var generated = await Database.MixItems.Select(item => item.TrackId).Distinct().ToListAsync(Ct);
Assert.Equal([live.Id], generated);
var mix = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 5);
await SeedAsync(mix, TestEntities.MixItem(mix.Id, live.Id, 0), TestEntities.MixItem(mix.Id, hidden.Id, 1));
var byId = await new GetMixByIdQueryHandler(Database).Handle(new GetMixByIdQuery(owner.Id, mix.Id), Ct);
var list = await new GetMixesByUserIdQueryHandler(Database).Handle(new GetMixesByUserIdQuery(owner.Id), Ct);
Assert.Equal([live.Id], byId.Value.Items.Select(i => i.TrackId));
Assert.Equal(1, byId.Value.ItemCount);
var listed = list.Single(m => m.Id == mix.Id);
Assert.Equal(1, listed.ItemCount);
Assert.Equal([live.Id], listed.Items.Select(i => i.TrackId));
}
[Fact]
public async Task Albums_HideUnpublishedTracksEverywhere()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var album = TestEntities.Album(owner.Id);
await SeedAsync(
album,
new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 },
TestEntities.ListeningHistory(other.Id, hidden.Id));
var tracks = await new GetAlbumTracksQueryHandler(Database).Handle(new GetAlbumTracksQuery(album.Id, 1, 20), Ct);
var detail = await new GetAlbumByIdQueryHandler(Database).Handle(new GetAlbumByIdQuery(album.Id), Ct);
var byUser = await new GetAlbumsByUserIdQueryHandler(Database).Handle(new GetAlbumsByUserIdQuery(owner.Id, 1, 20), Ct);
var all = await new GetAlbumsQueryHandler(Database).Handle(new GetAlbumsQuery(null, 1, 20), Ct);
var recent = await new GetRecentlyListenedAlbumsQueryHandler(Database).Handle(new GetRecentlyListenedAlbumsQuery(other.Id, 10), Ct);
var ids = await new GetAlbumTrackIdsQueryHandler(Database).Handle(new GetAlbumTrackIdsQuery(owner.Id, album.Id), Ct);
Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id));
Assert.Equal(1, detail.Value.TrackCount);
Assert.Equal([live.Id], detail.Value.CoverTrackIds);
Assert.Equal(1, byUser.Value.Items.Single().TrackCount);
Assert.Equal(1, all.Items.Single().TrackCount);
Assert.Empty(recent);
Assert.Equal([hidden.Id, live.Id], ids.Value.TrackIds);
}
[Fact]
public async Task PlayLists_HideUnpublishedTracksAndRejectAddingThem()
{
var (owner, other, live, hidden) = await SeedTracksAsync();
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Public);
await SeedAsync(
playList,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = hidden.Id, Position = 0 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = live.Id, Position = 1 });
var tracks = await new GetPlayListTracksQueryHandler(Database).Handle(new GetPlayListTracksQuery(playList.Id, 1, 20, owner.Id), Ct);
var detail = await new GetPlayListByIdQueryHandler(Database).Handle(new GetPlayListByIdQuery(playList.Id, other.Id), Ct);
var add = await new AddTracksToPlayListCommandHandler(Database, NoOpLogger<AddTracksToPlayListCommandHandler>())
.Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [hidden.Id]), Ct);
Assert.Equal([live.Id], tracks.Value.Items.Select(t => t.Id));
Assert.Equal(1, detail.Value.TrackCount);
Assert.Equal(live.DurationSeconds, detail.Value.DurationSeconds);
Assert.Equal([live.Id], detail.Value.CoverTrackIds);
Assert.Equal(ErrorType.NotFound, add.FirstError.Type);
}
[Fact]
public async Task AddAlbumToPlayList_SkipsUnpublishedTracks()
{
var (owner, _, live, hidden) = await SeedTracksAsync();
var album = TestEntities.Album(owner.Id);
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(
album, playList,
new AlbumHasTrack { AlbumId = album.Id, TrackId = hidden.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = live.Id, TrackNumber = 2 });
var result = await new AddAlbumToPlayListCommandHandler(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>())
.Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), Ct);
Assert.Equal(1, result.Value.AddedCount);
Assert.Equal([live.Id], await Database.PlayListHasTracks.Select(link => link.TrackId).ToListAsync(Ct));
}
}
}
@@ -113,6 +113,32 @@ namespace Musify.Infrastructure.Tests.MassTransit
slip.Itinerary.Select(activity => activity.Name)); slip.Itinerary.Select(activity => activity.Name));
} }
[Fact]
public async Task BuildTrack_ProcessedWithoutOwnPicture_NeverRemovesThePresets()
{
var track = await SeedTrackAsync(
new TrackPictures
{
SmallName = "PresetSmallPicture.webp",
MediumName = "PresetMediumPicture.webp",
LargeName = "PresetLargePicture.webp",
ProcessingStatus = ProcessingStatus.Completed
},
new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkTrackAsRemoving,
ActivityNames.RemoveTrackOriginalAudio,
ActivityNames.RemoveTrackProcessedAudio,
ActivityNames.DeleteTrackFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact] [Fact]
public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio()
{ {
@@ -33,6 +33,24 @@ namespace Musify.Infrastructure.Tests.MassTransit
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct)); Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
} }
[Fact]
public async Task Track_WithoutPicture_CompletesOnceTheAudioIsProcessed()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(
new CreateTrackResourcesEvent(id, null, Guid.NewGuid(), "bucket", null, null, "audio-source", "audio", "audio-folder", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackAudioProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact] [Fact]
public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed()
{ {
@@ -88,6 +88,43 @@ namespace Musify.Infrastructure.Tests.MassTransit
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent);
} }
[Fact]
public void CreateTrack_WithPicture_CopiesBothFilesAndConsumesBothIntents()
{
var audioIntentId = Guid.NewGuid();
var message = new CreateTrackResourcesEvent(
SubjectId, IntentId, audioIntentId, "bucket", "picture-source", "picture-final", "audio-source", "audio-final", "audio-folder", Sizes);
var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build();
Assert.Equal(
[ActivityNames.CopyPictureToFinal, ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents],
slip.Itinerary.Select(activity => activity.Name));
Assert.Equal("picture-final", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]);
var consume = new Dictionary<string, object>(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal([IntentId, audioIntentId], (IEnumerable<Guid>)consume["IntentIds"]);
}
[Fact]
public void CreateTrack_WithoutPicture_SkipsThePictureCopyAndOnlyConsumesTheAudioIntent()
{
var audioIntentId = Guid.NewGuid();
var message = new CreateTrackResourcesEvent(
SubjectId, null, audioIntentId, "bucket", null, null, "audio-source", "audio-final", "audio-folder", Sizes);
var slip = new CreateTrackRoutingSlipBuilder().Build(message, CorrelationId).Build();
Assert.Equal(
[ActivityNames.CopyAudioToFinal, ActivityNames.ConsumeUploadIntents, ActivityNames.PublishTrackProcessingEvents],
slip.Itinerary.Select(activity => activity.Name));
Assert.False(slip.Variables.ContainsKey(RoutingSlipVariableNames.Workflow.PictureKey));
Assert.Equal("audio-final", slip.Variables[RoutingSlipVariableNames.Workflow.AudioKey]);
var consume = new Dictionary<string, object>(slip.Itinerary[1].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal([audioIntentId], (IEnumerable<Guid>)consume["IntentIds"]);
var publish = new Dictionary<string, object>(slip.Itinerary[2].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Null(publish["FinalPictureKey"]);
}
[Fact] [Fact]
public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate()
{ {