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
@@ -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);