Changes
This commit is contained in:
@@ -9,7 +9,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
@@ -50,7 +49,6 @@ namespace Musify.Application.Albums
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Album track numbers are user-visible, so they start at 1.
|
||||
var nextTrackNumber = (await database.AlbumHasTracks
|
||||
.Where(link => link.AlbumId == request.AlbumId)
|
||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||
|
||||
@@ -30,7 +30,6 @@ namespace Musify.Application.Albums
|
||||
var album = found.Value;
|
||||
var title = request.Title.Trim();
|
||||
|
||||
// This is a full replace of the plain fields; the picture has its own command.
|
||||
album.Title = title;
|
||||
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
|
||||
|
||||
@@ -13,7 +13,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
/// <summary>Replaces an album's picture with a previously uploaded image; the plain fields are untouched.</summary>
|
||||
public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId)
|
||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
|
||||
@@ -83,7 +83,6 @@ namespace Musify.Application.Configuration
|
||||
|
||||
public static class PictureSizeParser
|
||||
{
|
||||
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
||||
public static PictureSize Parse(string? value) =>
|
||||
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||
? size
|
||||
|
||||
@@ -2,7 +2,6 @@ namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IStorageService
|
||||
{
|
||||
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
||||
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
@@ -44,7 +44,6 @@ namespace Musify.Application.Likes
|
||||
nowLiked = true;
|
||||
}
|
||||
|
||||
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
|
||||
@@ -60,7 +60,6 @@ namespace Musify.Application.Pictures
|
||||
_ => source.Medium
|
||||
};
|
||||
|
||||
// The resized files only exist once processing has finished; until then serve the original.
|
||||
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||
: !string.IsNullOrEmpty(source.Original)
|
||||
|
||||
@@ -7,7 +7,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Pictures
|
||||
{
|
||||
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
||||
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||
|
||||
public static class PictureSourceChange
|
||||
|
||||
@@ -11,7 +11,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
/// <summary>Appends every track of an album to a playlist in album order, skipping the ones it already has.</summary>
|
||||
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
||||
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
||||
|
||||
@@ -54,7 +53,6 @@ namespace Musify.Application.PlayLists
|
||||
if (newTrackIds.Count == 0)
|
||||
return new AddAlbumToPlayListResponse(0);
|
||||
|
||||
// Playlist positions are internal indexes, so they start at 0.
|
||||
var nextPosition = existingTrackIds.Count > 0
|
||||
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
|
||||
: 0;
|
||||
|
||||
@@ -9,7 +9,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
@@ -46,7 +45,6 @@ namespace Musify.Application.PlayLists
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Playlist positions are internal indexes, so they start at 0.
|
||||
var playListTracksQuery = database.PlayListHasTracks
|
||||
.Where(link => link.PlayListId == request.PlayListId);
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
var playList = found.Value;
|
||||
|
||||
// This is a full replace of the plain fields; the picture has its own command.
|
||||
playList.Name = request.NewName.Trim();
|
||||
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||
|
||||
@@ -13,7 +13,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
/// <summary>Replaces a playlist's picture with a previously uploaded image; the plain fields are untouched.</summary>
|
||||
public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId)
|
||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Services
|
||||
{
|
||||
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
||||
public sealed class TrackStreamIssuer(
|
||||
IStreamTicketService ticketService,
|
||||
TrackConfiguration trackConfiguration,
|
||||
|
||||
@@ -24,7 +24,6 @@ namespace Musify.Application.Services
|
||||
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,
|
||||
|
||||
@@ -7,10 +7,6 @@ namespace Musify.Application.Shared
|
||||
{
|
||||
public static class OwnershipExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
|
||||
/// or not active, Forbidden when someone else owns it.
|
||||
/// </summary>
|
||||
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
||||
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
||||
where T : class, IOwnedEntity, IHasLifeCycle
|
||||
|
||||
@@ -18,7 +18,6 @@ namespace Musify.Application.Shared
|
||||
|
||||
public static class PaginationExtensions
|
||||
{
|
||||
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
|
||||
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
||||
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
/// <summary>Starts a listen: records it for signed-in users and issues the stream ticket.</summary>
|
||||
public record StartListeningCommand(Guid TrackId, long? UserId)
|
||||
: ICommand<ErrorOr<TrackStreamResponse>>;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.UploadIntents
|
||||
{
|
||||
/// <summary>Deletes temporary upload objects that nobody claimed within the retention window.</summary>
|
||||
public record DeleteStaleTempUploadsCommand : ICommand<int>;
|
||||
|
||||
public class DeleteStaleTempUploadsCommandHandler(
|
||||
|
||||
@@ -7,7 +7,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.UploadIntents
|
||||
{
|
||||
/// <summary>Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window.</summary>
|
||||
public record ExpireUploadIntentsCommand : ICommand<int>;
|
||||
|
||||
public class ExpireUploadIntentsCommandHandler(
|
||||
|
||||
@@ -38,7 +38,6 @@ namespace Musify.Application.Users
|
||||
FollowedId = request.FollowedId
|
||||
}, cancellationToken);
|
||||
|
||||
// A concurrent double click can hit the unique index; already following is a successful no-op.
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Domain.ValueObjects
|
||||
{
|
||||
/// <summary>The picture file names of an album or playlist. Resized names stay null until processing finishes.</summary>
|
||||
public class EntityPictures
|
||||
{
|
||||
[Required]
|
||||
|
||||
@@ -2,7 +2,6 @@ namespace Musify.Domain.ValueObjects
|
||||
{
|
||||
public enum Genre
|
||||
{
|
||||
/// <summary>Unset value (the enum default); it is not a real genre and is never stored or offered to clients.</summary>
|
||||
None = 0,
|
||||
Pop = 1,
|
||||
Rock = 2,
|
||||
|
||||
@@ -4,7 +4,6 @@ using Musify.Application.Configuration;
|
||||
|
||||
namespace Musify.Infrastructure.Jobs
|
||||
{
|
||||
/// <summary>Registers every recurring Hangfire job when the host starts.</summary>
|
||||
public sealed class RecurringJobsRegistrar(
|
||||
IRecurringJobManager recurringJobManager,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
@@ -30,7 +29,6 @@ namespace Musify.Infrastructure.Jobs
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes.</summary>
|
||||
internal static string EveryCron(int intervalSeconds)
|
||||
{
|
||||
var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0));
|
||||
|
||||
@@ -5,7 +5,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.Activities.Audio
|
||||
{
|
||||
/// <summary>Shared bookkeeping of the audio processing activities.</summary>
|
||||
internal static class TrackAudioStatus
|
||||
{
|
||||
public static async Task<Track> LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) =>
|
||||
|
||||
-4
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
|
||||
{
|
||||
/// <summary>Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped.</summary>
|
||||
internal abstract class MarkLifeCycleActivity<TEntity>(IDatabase database, ILogger logger)
|
||||
: IExecuteActivity<MarkLifeCycleArguments>
|
||||
where TEntity : class, IHasLifeCycle
|
||||
@@ -33,7 +32,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes an entity row. A missing entity is skipped.</summary>
|
||||
internal abstract class DeleteEntityActivity<TEntity>(IDatabase database, ILogger logger)
|
||||
: IExecuteActivity<DeleteEntityArguments>
|
||||
where TEntity : class, IHasLifeCycle
|
||||
@@ -58,8 +56,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
|
||||
}
|
||||
}
|
||||
|
||||
// One concrete activity per entity keeps a separate queue for each of them:
|
||||
// the endpoint name is the kebab-cased class name.
|
||||
internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger<MarkTrackLifeCycleActivity> logger)
|
||||
: MarkLifeCycleActivity<Track>(database, logger)
|
||||
{
|
||||
|
||||
-2
@@ -12,7 +12,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
||||
{
|
||||
internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large);
|
||||
|
||||
/// <summary>Stores the processed picture names on the owner and reports it; compensation restores the previous names.</summary>
|
||||
internal abstract class UpdatePicturesActivity<TEntity>(
|
||||
IDatabase database,
|
||||
IPublishEndpoint publishEndpoint,
|
||||
@@ -24,7 +23,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
||||
|
||||
protected abstract Task<TEntity?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads the current names; throws when the entity has no pending picture.</summary>
|
||||
protected abstract PictureNames Read(TEntity entity);
|
||||
|
||||
protected abstract void Write(TEntity entity, PictureNames names, bool succeeded);
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Musify.Infrastructure.MassTransit.Consumers
|
||||
{
|
||||
internal static class ProcessingFailedCleanup
|
||||
{
|
||||
/// <summary>Removes whatever files were left behind and marks the entity as failed.</summary>
|
||||
public static Task ExecuteAsync(
|
||||
IBus bus,
|
||||
Guid subjectId,
|
||||
|
||||
-2
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
{
|
||||
/// <summary>Marks an entity as removing, deletes its files from the bucket and finally deletes its row.</summary>
|
||||
public class DeleteRoutingSlipBuilder(
|
||||
IDatabase database,
|
||||
TrackConfiguration trackConfiguration,
|
||||
@@ -48,7 +47,6 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
|
||||
}
|
||||
|
||||
// Unprocessed tracks still point at the shared preset pictures, which must never be deleted.
|
||||
if (track.Pictures.IsProcessed)
|
||||
{
|
||||
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
|
||||
|
||||
-4
@@ -8,10 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
{
|
||||
/// <summary>
|
||||
/// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow.
|
||||
/// Creation faults mark the whole entity as failed; update faults only report the picture as failed.
|
||||
/// </summary>
|
||||
public class PictureSourceRoutingSlipBuilder
|
||||
{
|
||||
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) =>
|
||||
|
||||
-1
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
{
|
||||
/// <summary>Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner.</summary>
|
||||
public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
|
||||
{
|
||||
public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) =>
|
||||
|
||||
@@ -6,14 +6,12 @@ using Musify.Infrastructure.MassTransit.Consumers;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip
|
||||
{
|
||||
/// <summary>Shared building blocks for every routing slip in the worker.</summary>
|
||||
internal static class RoutingSlips
|
||||
{
|
||||
public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute");
|
||||
|
||||
public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}");
|
||||
|
||||
/// <summary>A new slip that carries the correlation id and always cleans its temporary directory up.</summary>
|
||||
public static RoutingSlipBuilder Create(Guid? correlationId)
|
||||
{
|
||||
var builder = new RoutingSlipBuilder(NewId.NextGuid());
|
||||
@@ -32,7 +30,6 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>Publishes a failure event for the subject when the slip faults.</summary>
|
||||
public static RoutingSlipBuilder TrackFaults(
|
||||
this RoutingSlipBuilder builder, Guid subjectId, string processKind)
|
||||
{
|
||||
|
||||
@@ -5,9 +5,6 @@ using Musify.Infrastructure.MassTransit.Sagas;
|
||||
|
||||
namespace Musify.Infrastructure.Persistence
|
||||
{
|
||||
/// <summary>
|
||||
/// Entity model shared by <see cref="Database"/> and the in-memory test database, so both always agree.
|
||||
/// </summary>
|
||||
public static class ModelConfiguration
|
||||
{
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
|
||||
@@ -2,7 +2,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.Models
|
||||
{
|
||||
/// <summary>Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400.</summary>
|
||||
public readonly record struct GenreParameter(Genre Value)
|
||||
{
|
||||
public static bool TryParse(string? value, out GenreParameter result)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Api.Models
|
||||
{
|
||||
/// <summary>Query-string binding for <see cref="PageRequest"/>, keeping the camelCase parameter names of the API.</summary>
|
||||
public record PageQuery(
|
||||
[property: FromQuery(Name = "pageNumber")] int PageNumber = 1,
|
||||
[property: FromQuery(Name = "pageSize")] int PageSize = 20);
|
||||
|
||||
@@ -40,8 +40,6 @@ namespace Musify.Api.OpenApi
|
||||
|
||||
options.AddSchemaTransformer((schema, context, _) =>
|
||||
{
|
||||
// Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53).
|
||||
// Request DTOs only carry byte sizes, which may be sent as numbers or strings.
|
||||
if (context.JsonPropertyInfo is { } property
|
||||
&& (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?)))
|
||||
{
|
||||
@@ -57,7 +55,6 @@ namespace Musify.Api.OpenApi
|
||||
|
||||
options.AddOperationTransformer((operation, context, _) =>
|
||||
{
|
||||
// Route parameters constrained with :long are user ids, which travel as strings like every other long.
|
||||
foreach (var parameter in operation.Parameters?.OfType<OpenApiParameter>() ?? [])
|
||||
{
|
||||
if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema)
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Musify.Infrastructure.Tests.MassTransit
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
// Track
|
||||
|
||||
[Fact]
|
||||
public async Task Track_PictureAndAudioProcessed_CompletesTheSaga()
|
||||
@@ -86,7 +85,6 @@ namespace Musify.Infrastructure.Tests.MassTransit
|
||||
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
|
||||
}
|
||||
|
||||
// Album
|
||||
|
||||
[Fact]
|
||||
public async Task Album_PictureProcessed_CompletesTheSaga()
|
||||
@@ -137,7 +135,6 @@ namespace Musify.Infrastructure.Tests.MassTransit
|
||||
Assert.False(await harness.Published.Any<Fault<AlbumPictureProcessingFailed>>(Ct));
|
||||
}
|
||||
|
||||
// PlayList
|
||||
|
||||
[Fact]
|
||||
public async Task PlayList_PictureProcessed_CompletesTheSaga()
|
||||
|
||||
@@ -16,7 +16,6 @@ using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.MassTransit
|
||||
{
|
||||
/// <summary>Runs the picture-update activities inside real routing slips on the MassTransit test harness.</summary>
|
||||
[Collection(InfrastructureCollection.Name)]
|
||||
public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user