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

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

Albums
- AlbumType (Album, EP, Single) on create, update and responses.
- CreateAlbumRequest.trackIds creates the album and links the tracks in a
  single save.
This commit is contained in:
2026-09-25 01:37:55 +02:00
parent d839a72700
commit ebfd41b160
79 changed files with 3076 additions and 151 deletions
@@ -19,7 +19,9 @@ namespace Musify.Application.Albums
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
Guid PictureIntentId,
AlbumType Type = AlbumType.Album,
IReadOnlyList<Guid>? TrackIds = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
@@ -38,6 +40,21 @@ namespace Musify.Application.Albums
if (!userExists)
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(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
@@ -53,11 +70,19 @@ namespace Musify.Application.Albums
NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Type = request.Type,
Pictures = picture.Pictures
};
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(
new CreateAlbumResourcesEvent(
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);
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
.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)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
@@ -20,6 +20,7 @@ namespace Musify.Application.Albums
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.Join(
listens,
albumTrack => albumTrack.TrackId,
@@ -1,4 +1,5 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses
{
@@ -14,7 +15,8 @@ namespace Musify.Application.Albums.Responses
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
IReadOnlyList<Guid> CoverTrackIds,
AlbumType Type)
{
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
@@ -35,7 +37,8 @@ namespace Musify.Application.Albums.Responses
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? []);
coverTrackIds ?? [],
album.Type);
}
}
}
@@ -17,16 +17,18 @@ namespace Musify.Application.Albums.Responses
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count,
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()));
.ToList(),
album.Type));
}
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums
{
@@ -13,7 +14,8 @@ namespace Musify.Application.Albums
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear)
int? ReleaseYear,
AlbumType? Type = null)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
@@ -34,6 +36,7 @@ namespace Musify.Application.Albums
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
album.ReleaseYear = request.ReleaseYear;
album.Type = request.Type ?? album.Type;
await database.SaveChangesAsync(cancellationToken);
@@ -10,10 +10,10 @@ namespace Musify.Application.Configuration
public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
[Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
@@ -4,11 +4,11 @@ namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent(
Guid TrackId,
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
string? PictureSourceKey,
string? PictureDestinationKey,
string AudioSourceKey,
string AudioDestinationKey,
string AudioProcessedFolderKey,
@@ -15,7 +15,7 @@ namespace Musify.Application.Genres
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
@@ -20,7 +20,7 @@ namespace Musify.Application.Likes
{
return await database.TrackLikes
.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)
.ThenBy(like => like.Id)
.Select(like => like.Track)
@@ -21,7 +21,7 @@ namespace Musify.Application.Likes
{
var trackExists = await database.Tracks
.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)
return AppErrors.NotFound("Track", request.TrackId);
@@ -25,7 +25,7 @@ namespace Musify.Application.Mixes
var libraryTrackIds = await database.Tracks
.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)
.ToListAsync(cancellationToken);
@@ -25,7 +25,13 @@ namespace Musify.Application.Mixes
mix => mix.Id,
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))
.ToList();
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
@@ -35,7 +41,7 @@ namespace Musify.Application.Mixes
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{
var orderedItems = orderedItemsByMix[mix.Id];
var orderedItems = visibleItemsByMix[mix.Id];
var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId))
@@ -9,6 +9,22 @@ namespace Musify.Application.Mixes
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(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
@@ -19,7 +35,7 @@ namespace Musify.Application.Mixes
var tracksById = await database.Tracks
.AsNoTracking()
.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) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
@@ -35,10 +35,7 @@ namespace Musify.Application.Pictures
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => (await database.Tracks.AsNoTracking()
.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.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.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))
@@ -71,5 +68,34 @@ namespace Musify.Application.Pictures
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
.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)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
@@ -27,7 +27,7 @@ namespace Musify.Application.PlayLists
var existingIds = await database.Tracks
.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)
.ToListAsync(cancellationToken);
@@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists
return await database.PlayListHasTracks
.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)
.ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track)
@@ -25,14 +25,15 @@ namespace Musify.Application.PlayLists.Responses
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
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
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
}
@@ -15,7 +15,8 @@ namespace Musify.Application.Services
PictureRoutes Routes,
string FileType,
string ContentType,
long? ExpectedSizeBytes);
long? ExpectedSizeBytes,
int? SharedKeyGroup = null);
public sealed record IssuedUpload(
Guid IntentId,
@@ -43,11 +44,15 @@ namespace Musify.Application.Services
if (!userExists)
return AppErrors.NotFound("User", userId);
var sharedKeys = new Dictionary<int, string>();
var planned = requests
.Select(request =>
{
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
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);
return (Request: request, ObjectName: objectName, Key: key, Size: size);
})
@@ -16,7 +16,7 @@ namespace Musify.Application.Tracks
public record CreateTrackCommand(
long UserId,
string Title,
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
@@ -45,20 +45,27 @@ namespace Musify.Application.Tracks
if (request.PictureIntentId == request.AudioIntentId)
return Error.Validation(description: "Picture and audio must use different upload intents.");
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
if (pictureValidation.IsError)
return pictureValidation.Errors;
UploadIntent? pictureIntent = null;
if (request.PictureIntentId is { } pictureIntentId)
{
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(
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
if (audioValidation.IsError)
return audioValidation.Errors;
var pictureIntent = pictureValidation.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 audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
@@ -71,11 +78,11 @@ namespace Musify.Application.Tracks
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures
{
OriginalName = pictureIntent.ObjectName,
OriginalName = pictureIntent?.ObjectName,
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
ProcessingStatus = pictureIntent == null ? ProcessingStatus.Completed : ProcessingStatus.Pending
},
Audio = new TrackAudio
{
@@ -94,10 +101,10 @@ namespace Musify.Application.Tracks
await eventBus.PublishAsync(
new CreateTrackResourcesEvent(
trackEntity.Id,
pictureIntent.Id,
pictureIntent?.Id,
audioIntent.Id,
pictureIntent.Bucket,
pictureIntent.Key,
audioIntent.Bucket,
pictureIntent?.Key,
finalPictureKey,
audioIntent.Key,
finalAudioKey,
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
.AsNoTracking()
.Include(t => t.Track.Owner)
.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)
.ThenBy(t => t.Id)
.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
.AsNoTracking()
.Active()
.WherePublished()
.Where(t => recentTrackIds.Contains(t.Id))
.SelectResponse()
.ToDictionaryAsync(t => t.Id, cancellationToken);
@@ -7,7 +7,7 @@ using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks
{
public record GetTrackByIdQuery(Guid TrackId)
public record GetTrackByIdQuery(Guid TrackId, long? ViewerId = null)
: IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetTrackByIdQueryHandler(IDatabase database)
@@ -18,6 +18,7 @@ namespace Musify.Application.Tracks
var track = await database.Tracks
.AsNoTracking()
.Active()
.VisibleTo(request.ViewerId)
.Where(t => t.Id == request.TrackId)
.SelectResponse()
.SingleOrDefaultAsync(cancellationToken);
@@ -18,18 +18,23 @@ namespace Musify.Application.Tracks
.AsNoTracking()
.Active()
.Where(track => track.OwnerUserId == request.UserId)
.GroupBy(track => track.Audio.TranscodeStatus)
.Select(group => new { Status = group.Key, Count = group.Count() })
.GroupBy(track => new { track.Audio.TranscodeStatus, track.IsPublished })
.Select(group => new { group.Key.TranscodeStatus, group.Key.IsPublished, Count = group.Count() })
.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(
counts.Sum(c => c.Count),
total,
CountOf(ProcessingStatus.Pending),
CountOf(ProcessingStatus.Processing),
CountOf(ProcessingStatus.Completed),
CountOf(ProcessingStatus.Failed));
CountOf(ProcessingStatus.Failed),
published,
total - published);
}
}
}
@@ -17,7 +17,8 @@ namespace Musify.Application.Tracks
{
var tracksQuery = database.Tracks
.AsNoTracking()
.Active();
.Active()
.WherePublished();
if (!string.IsNullOrWhiteSpace(request.Name))
{
@@ -13,7 +13,9 @@ namespace Musify.Application.Tracks
string? Name,
int PageNumber,
int PageSize,
IReadOnlyList<ProcessingStatus>? Statuses = null)
IReadOnlyList<ProcessingStatus>? Statuses = null,
long? ViewerId = null,
bool? IsPublished = null)
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
public class GetTracksByUserIdQueryHandler(IDatabase database)
@@ -26,6 +28,9 @@ namespace Musify.Application.Tracks
.Active()
.Where(track => track.OwnerUserId == request.UserId);
if (request.ViewerId != request.UserId)
tracksQuery = tracksQuery.WherePublished();
if (!string.IsNullOrWhiteSpace(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));
}
if (request.IsPublished is { } isPublished)
tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished);
return await tracksQuery
.OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id)
@@ -7,14 +7,17 @@ using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record AudioUploadSpec(string FileType, string ContentType, long? ExpectedSizeBytes = null);
public record RequestTrackUploadUrlsCommand(
long UserId,
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
string? PictureFileType,
string? PictureContentType,
string? AudioFileType = null,
string? AudioContentType = null,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null)
long? ExpectedAudioSizeBytes = null,
IReadOnlyList<AudioUploadSpec>? Audios = null)
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
public class RequestTrackUploadUrlsCommandHandler(
@@ -24,35 +27,75 @@ namespace Musify.Application.Tracks
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
{
private const int PictureKeyGroup = 1;
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 issued = await issuer.IssueAsync(
request.UserId,
[
new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes),
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)
],
cancellationToken);
var uploads = new List<UploadRequest>();
if (hasPicture)
{
for (var index = 0; index < audios.Count; index++)
{
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)
return issued.Errors;
var picture = issued.Value[0];
var audio = issued.Value[1];
var pictures = hasPicture ? issued.Value.Take(audios.Count).ToList() : [];
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(
picture.IntentId,
audio.IntentId,
firstPicture?.IntentId,
firstAudio.IntentId,
storageConfiguration.Bucket,
picture.Key,
picture.ObjectName,
picture.ContentType,
picture.UploadUrl,
audio.Key,
audio.ObjectName,
audio.ContentType,
audio.UploadUrl,
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
firstPicture?.Key,
firstPicture?.ObjectName,
firstPicture?.ContentType,
firstPicture?.UploadUrl,
firstAudio.Key,
firstAudio.ObjectName,
firstAudio.ContentType,
firstAudio.UploadUrl,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
items);
}
private static IReadOnlyList<AudioUploadSpec> ResolveAudios(RequestTrackUploadUrlsCommand request)
{
if (request.Audios is { Count: > 0 } audios)
return audios;
return request.AudioFileType != null && request.AudioContentType != null
? [new AudioUploadSpec(request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)]
: [];
}
}
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Tracks.Responses
{
public record SetTracksPublishedResponse(
IReadOnlyList<Guid> UpdatedTrackIds,
IReadOnlyList<Guid> SkippedTrackIds);
}
@@ -14,7 +14,8 @@ namespace Musify.Application.Tracks.Responses
DateTime UpdatedAt,
long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
bool IsExplicit,
bool IsPublished)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new(
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
track.UpdatedAt,
track.OwnerUserId,
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) =>
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) =>
query.Select(track => new TrackApplicationResponse(
track.Id,
@@ -20,7 +26,8 @@ namespace Musify.Application.Tracks.Responses
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit));
track.IsExplicit,
track.IsPublished));
public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query) =>
query.Select(track => new UserTrackApplicationResponse(
@@ -35,6 +42,7 @@ namespace Musify.Application.Tracks.Responses
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit,
track.IsPublished,
track.AlbumTracks
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.AddedAt)
@@ -5,5 +5,7 @@ namespace Musify.Application.Tracks.Responses
int Pending,
int Processing,
int Completed,
int Failed);
int Failed,
int Published,
int Unpublished);
}
@@ -1,16 +1,25 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackAudioUpload(
Guid IntentId,
Guid? PictureIntentId,
string Key,
string Name,
string ContentType,
string UploadUrl);
public record TrackUploadUrlsResponse(
Guid PictureIntentId,
Guid? PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureKey,
string PictureName,
string PictureContentType,
string PictureUploadUrl,
string? PictureKey,
string? PictureName,
string? PictureContentType,
string? PictureUploadUrl,
string AudioKey,
string AudioName,
string AudioContentType,
string AudioUploadUrl,
int ExpiresInSeconds);
int ExpiresInSeconds,
IReadOnlyList<TrackAudioUpload> Audios);
}
@@ -16,5 +16,6 @@ namespace Musify.Application.Tracks.Responses
long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit,
bool IsPublished,
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()
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.VisibleTo(request.UserId)
.Select(t => new { t.Id, t.Audio })
.SingleOrDefaultAsync(cancellationToken);
@@ -22,6 +22,8 @@ namespace Musify.Domain.Entities
public int? ReleaseYear { get; set; }
public AlbumType Type { get; set; }
public EntityPictures? Pictures { get; set; }
public required long OwnerUserId { get; set; }
@@ -28,6 +28,8 @@ namespace Musify.Domain.Entities
public bool IsExplicit { get; set; }
public bool IsPublished { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
namespace Musify.Domain.ValueObjects
{
public enum AlbumType
{
Album = 0,
EP = 1,
[SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "Part of the public API contract.")]
Single = 2
}
}
@@ -16,13 +16,16 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
{
var arguments = executeContext.Arguments;
await publishEndpoint.Publish(
new UpdateTrackPictureEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
if (arguments.FinalPictureKey != null)
{
await publishEndpoint.Publish(
new UpdateTrackPictureEvent(
arguments.TrackId,
arguments.Bucket,
arguments.FinalPictureKey,
arguments.Sizes),
executeContext.CancellationToken);
}
await publishEndpoint.Publish(
new UpdateTrackAudioEvent(
@@ -5,7 +5,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments
public record PublishTrackProcessingEventsArguments(
Guid TrackId,
string Bucket,
string FinalPictureKey,
string? FinalPictureKey,
string FinalAudioKey,
string AudioDestinationFolderKey,
ImageSizes Sizes);
@@ -11,11 +11,21 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
{
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
{
var builder = RoutingSlips.Create(correlationId)
.AddStep(
var builder = RoutingSlips.Create(correlationId);
var hasPicture = message.PictureIntentId != null
&& message.PictureSourceKey != null
&& message.PictureDestinationKey != null;
if (hasPicture)
{
builder.AddStep(
ActivityNames.CopyPictureToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey))
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey!, message.Bucket, message.PictureDestinationKey!));
}
builder
.AddStep(
ActivityNames.CopyAudioToFinal,
CopyFileInBucketActivity.ExecuteEndpointName,
@@ -23,21 +33,23 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
.AddStep(
ActivityNames.ConsumeUploadIntents,
ConsumeUploadIntentsActivity.ExecuteEndpointName,
new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId]))
new ConsumeUploadIntentsArguments(
hasPicture ? [message.PictureIntentId!.Value, message.AudioIntentId] : [message.AudioIntentId]))
.AddStep(
ActivityNames.PublishTrackProcessingEvents,
PublishTrackProcessingEventsActivity.ExecuteEndpointName,
new PublishTrackProcessingEventsArguments(
message.TrackId,
message.Bucket,
message.PictureDestinationKey,
hasPicture ? message.PictureDestinationKey : null,
message.AudioDestinationKey,
message.AudioProcessedFolderKey,
message.Sizes))
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
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);
return builder;
@@ -47,7 +47,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
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.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.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.PictureProcessed = context.Message.PictureDestinationKey == null;
context.Saga.AudioKey = context.Message.AudioDestinationKey;
})
.TransitionTo(Processing));
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class TrackPublicationAndAlbumType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsPublished",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("UPDATE \"Tracks\" SET \"IsPublished\" = TRUE;");
migrationBuilder.AddColumn<int>(
name: "Type",
table: "Albums",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsPublished",
table: "Tracks");
migrationBuilder.DropColumn(
name: "Type",
table: "Albums");
}
}
}
@@ -224,6 +224,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -435,6 +438,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");