Files
Musify/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs
T
Admin ebfd41b160 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.
2026-09-25 01:37:55 +02:00

139 lines
5.6 KiB
C#

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,
int? SharedKeyGroup = null);
public sealed record IssuedUpload(
Guid IntentId,
string Key,
string ObjectName,
string ContentType,
string UploadUrl);
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 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);
})
.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: 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;
}
}