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