This commit is contained in:
2026-09-24 17:58:48 +02:00
parent e84c16cf92
commit 1a302491d1
37 changed files with 0 additions and 61 deletions
@@ -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) =>
@@ -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)
{
@@ -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,
@@ -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));
@@ -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) =>
@@ -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)
{