Deduplicate picture flows: shared upload issuer, cover query and picture source change

This commit is contained in:
2026-09-24 12:04:54 +02:00
parent 4b147d9ee3
commit 75d0c0817e
55 changed files with 667 additions and 1163 deletions
@@ -0,0 +1,133 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Pictures.Responses;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Services;
public sealed record UploadRequest(
UploadIntentPurpose Purpose,
PictureRoutes Routes,
string FileType,
string ContentType,
long? ExpectedSizeBytes);
public sealed record IssuedUpload(
Guid IntentId,
string Key,
string ObjectName,
string ContentType,
string UploadUrl);
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
public sealed class UploadIntentIssuer(
IDatabase database,
IStorageService storageService,
UploadIntentValidator uploadIntentValidator,
ApplicationStorageConfiguration storageConfiguration,
UploadIntentConfiguration uploadIntentConfiguration,
ILogger<UploadIntentIssuer> logger)
{
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
long userId,
IReadOnlyList<UploadRequest> requests,
CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == userId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", userId);
var planned = requests
.Select(request =>
{
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
return (Request: request, ObjectName: objectName, Key: key, Size: size);
})
.ToList();
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quota = await uploadIntentValidator.CheckQuotaAsync(
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
if (quota.IsError)
return quota.Errors;
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
var expiresAt = DateTime.UtcNow + expiresIn;
var issued = new List<IssuedUpload>(planned.Count);
foreach (var item in planned)
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken);
var intent = new UploadIntent
{
UserId = userId,
Bucket = storageConfiguration.Bucket,
Key = item.Key,
ObjectName = item.ObjectName,
ContentType = item.Request.ContentType,
ExpectedSizeBytes = item.Size,
Purpose = item.Request.Purpose,
ExpiresAt = expiresAt,
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
}
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
return issued;
}
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
{
var issued = await IssueAsync(userId, [request], cancellationToken);
return issued.IsError ? issued.Errors : issued.Value[0];
}
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
string fileType,
string contentType,
long? expectedSizeBytes,
CancellationToken cancellationToken)
{
var issued = await IssueOneAsync(
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
if (issued.IsError)
return issued.Errors;
return new PictureUploadResponse(
issued.Value.IntentId,
storageConfiguration.Bucket,
issued.Value.Key,
issued.Value.ObjectName,
issued.Value.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
issued.Value.UploadUrl);
}
private long DefaultSize(UploadIntentPurpose purpose) =>
purpose == UploadIntentPurpose.TrackAudio
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
}
@@ -2,40 +2,49 @@ using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
namespace Musify.Application.Services;
public sealed class UploadIntentValidator(
IDatabase database,
IStorageService storageService)
IStorageService storageService,
UploadIntentConfiguration config)
{
public async Task<ErrorOr<Success>> CheckQuotaAsync(
UploadIntentConfiguration config,
long userId,
long requiredBytes,
int requiredIntentCount,
CancellationToken cancellationToken)
{
var activeIntents = await database.UploadIntents
.AsNoTracking()
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
.ToListAsync(cancellationToken);
var now = DateTime.UtcNow;
var pictureDefault = config.DefaultExpectedPictureSizeBytes;
var audioDefault = config.DefaultExpectedAudioSizeBytes;
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
var active = await database.UploadIntents
.AsNoTracking()
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
.GroupBy(_ => 1)
.Select(group => new
{
Count = group.Count(),
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
})
.SingleOrDefaultAsync(cancellationToken);
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
return Result.Success;
}
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
UploadIntentConfiguration config,
Guid intentId,
long userId,
UploadIntentPurpose purpose,