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:
@@ -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.");
|
||||||
|
|
||||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
UploadIntent? pictureIntent = null;
|
||||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
if (request.PictureIntentId is { } pictureIntentId)
|
||||||
if (pictureValidation.IsError)
|
{
|
||||||
return pictureValidation.Errors;
|
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
|
pictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||||
|
if (pictureValidation.IsError)
|
||||||
|
return pictureValidation.Errors;
|
||||||
|
|
||||||
|
pictureIntent = pictureValidation.Value;
|
||||||
|
}
|
||||||
|
|
||||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
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();
|
||||||
|
|
||||||
|
var items = audioUploads
|
||||||
|
.Select((audio, index) => new TrackAudioUpload(
|
||||||
|
audio.IntentId,
|
||||||
|
hasPicture ? pictures[index].IntentId : null,
|
||||||
|
audio.Key,
|
||||||
|
audio.ObjectName,
|
||||||
|
audio.ContentType,
|
||||||
|
audio.UploadUrl))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var firstPicture = pictures.Count > 0 ? pictures[0] : null;
|
||||||
|
var firstAudio = audioUploads[0];
|
||||||
|
|
||||||
return new TrackUploadUrlsResponse(
|
return new TrackUploadUrlsResponse(
|
||||||
picture.IntentId,
|
firstPicture?.IntentId,
|
||||||
audio.IntentId,
|
firstAudio.IntentId,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
picture.Key,
|
firstPicture?.Key,
|
||||||
picture.ObjectName,
|
firstPicture?.ObjectName,
|
||||||
picture.ContentType,
|
firstPicture?.ContentType,
|
||||||
picture.UploadUrl,
|
firstPicture?.UploadUrl,
|
||||||
audio.Key,
|
firstAudio.Key,
|
||||||
audio.ObjectName,
|
firstAudio.ObjectName,
|
||||||
audio.ContentType,
|
firstAudio.ContentType,
|
||||||
audio.UploadUrl,
|
firstAudio.UploadUrl,
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-7
@@ -16,13 +16,16 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
|||||||
{
|
{
|
||||||
var arguments = executeContext.Arguments;
|
var arguments = executeContext.Arguments;
|
||||||
|
|
||||||
await publishEndpoint.Publish(
|
if (arguments.FinalPictureKey != null)
|
||||||
new UpdateTrackPictureEvent(
|
{
|
||||||
arguments.TrackId,
|
await publishEndpoint.Publish(
|
||||||
arguments.Bucket,
|
new UpdateTrackPictureEvent(
|
||||||
arguments.FinalPictureKey,
|
arguments.TrackId,
|
||||||
arguments.Sizes),
|
arguments.Bucket,
|
||||||
executeContext.CancellationToken);
|
arguments.FinalPictureKey,
|
||||||
|
arguments.Sizes),
|
||||||
|
executeContext.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
await publishEndpoint.Publish(
|
await publishEndpoint.Publish(
|
||||||
new UpdateTrackAudioEvent(
|
new UpdateTrackAudioEvent(
|
||||||
|
|||||||
+1
-1
@@ -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);
|
||||||
|
|||||||
+18
-6
@@ -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;
|
||||||
|
|||||||
+1
-1
@@ -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));
|
||||||
|
|||||||
+1090
File diff suppressed because it is too large
Load Diff
+42
@@ -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);
|
||||||
|
|||||||
+11
-5
@@ -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();
|
||||||
|
|||||||
+40
-12
@@ -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, () =>
|
||||||
.MustBeFileType(Uploads.PictureFileTypes);
|
{
|
||||||
|
RuleFor(x => x.PictureFileType!)
|
||||||
|
.MustBeFileType(Uploads.PictureFileTypes);
|
||||||
|
|
||||||
RuleFor(x => x.PictureContentType)
|
RuleFor(x => x.PictureContentType!)
|
||||||
.MustBeContentType(Uploads.PictureContentTypes);
|
.MustBeContentType(Uploads.PictureContentTypes);
|
||||||
|
|
||||||
RuleFor(x => x.AudioFileType)
|
RuleFor(x => x.ExpectedPictureSizeBytes)
|
||||||
.MustBeFileType(Uploads.AudioFileTypes);
|
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||||
|
});
|
||||||
|
|
||||||
RuleFor(x => x.AudioContentType)
|
When(x => x.Audios is { Count: > 0 }, () =>
|
||||||
.MustBeContentType(Uploads.AudioContentTypes);
|
{
|
||||||
|
RuleFor(x => x.AudioFileType)
|
||||||
|
.Null()
|
||||||
|
.WithMessage("Use either the audio fields or the audios list, not both.");
|
||||||
|
|
||||||
RuleFor(x => x.ExpectedPictureSizeBytes)
|
RuleFor(x => x.AudioContentType)
|
||||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
.Null()
|
||||||
|
.WithMessage("Use either the audio fields or the audios list, not both.");
|
||||||
|
|
||||||
RuleFor(x => x.ExpectedAudioSizeBytes)
|
RuleFor(x => x.Audios!)
|
||||||
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
.Must(audios => audios.Count <= MaxAudiosPerRequest)
|
||||||
|
.WithMessage($"At most {MaxAudiosPerRequest} audio files can be requested at once.");
|
||||||
|
|
||||||
|
RuleForEach(x => x.Audios!).ChildRules(audio =>
|
||||||
|
{
|
||||||
|
audio.RuleFor(a => a.FileType).MustBeFileType(Uploads.AudioFileTypes);
|
||||||
|
audio.RuleFor(a => a.ContentType).MustBeContentType(Uploads.AudioContentTypes);
|
||||||
|
audio.RuleFor(a => a.ExpectedSizeBytes).MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||||
|
});
|
||||||
|
}).Otherwise(() =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.AudioFileType!)
|
||||||
|
.MustBeFileType(Uploads.AudioFileTypes);
|
||||||
|
|
||||||
|
RuleFor(x => x.AudioContentType!)
|
||||||
|
.MustBeContentType(Uploads.AudioContentTypes);
|
||||||
|
|
||||||
|
RuleFor(x => x.ExpectedAudioSizeBytes)
|
||||||
|
.MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user