Experimental changes

This commit is contained in:
2026-03-29 17:15:39 +02:00
parent 7d4265d47c
commit 3e54b5db43
35 changed files with 279 additions and 445 deletions
@@ -1,11 +0,0 @@
using Ardalis.Result;
namespace Musify.Application.Contracts.Infrastructure
{
public interface IAudioTranscoder
{
Task<Result> TranscodeToDashAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<Result<bool>> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,9 @@
namespace Musify.Application.Contracts.Infrastructure
{
public interface IAudioTranscoderService
{
Task<int> TranscodeToDashAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
}
}
@@ -1,11 +1,10 @@
using Ardalis.Result;
using Musify.Application.Users.Responses;
using Musify.Application.Users.Responses;
namespace Musify.Application.Contracts.Infrastructure
{
public interface IKeycloakUserService
{
Task<Result<KeycloakUserResponse>> GetUserByIdAsync(string keycloakId, CancellationToken cancellationToken);
Task<KeycloakUserResponse> GetUserByIdAsync(string keycloakId, CancellationToken cancellationToken);
Task<IReadOnlyCollection<KeycloakUserResponse>> GetUsersAsync(
CancellationToken cancellationToken,
@@ -1,9 +0,0 @@
using Ardalis.Result;
namespace Musify.Application.Contracts.Infrastructure
{
public interface IPictureHandler
{
Task<Result<Stream>> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Contracts.Infrastructure
{
public interface IPictureService
{
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
}
@@ -1,22 +0,0 @@
using Ardalis.Result;
namespace Musify.Application.Contracts.Infrastructure
{
public interface IStorageHandler
{
Task<Result<Stream>> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
Task<Result<string>> UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task<Result<string>> UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
Task<Result> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
Task<Result> RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<Result> CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken);
}
}
@@ -0,0 +1,20 @@
namespace Musify.Application.Contracts.Infrastructure
{
public interface IStorageService
{
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
Task TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken);
}
}
@@ -13,7 +13,7 @@ using Musify.Domain.Entities;
namespace Musify.Application.PlayLists.Handlers
{
public class CreatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageHandler storageHandler,
public class CreatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageService storageHandler,
ILogger<CreatePlayListCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration)
: IRequestHandler<CreatePlayListCommand, Task<Result<PlayListResponse>> >
{
@@ -80,16 +80,16 @@ namespace Musify.Application.PlayLists.Handlers
var pictureName = Guid.NewGuid() + picture.FileType;
var pictureKey = Path.Combine(playListConfiguration.Routes.OriginalPicturesPath, pictureName);
var uploadResult = await storageHandler.UploadFileAsync(picture.FileStream, picture.ContentType, storageConfiguration.BucketName, pictureKey, cancellationToken);
if (!uploadResult.IsSuccess)
try
{
logger.LogError("Failed to upload picture for playlist {PlayListId} to storage: {ErrorMessage}",
playList.Id, string.Join(", ", uploadResult.Errors));
return uploadResult.ToErrorResult();
await storageHandler.UploadFileAsync(picture.FileStream, picture.ContentType, storageConfiguration.BucketName, pictureKey, cancellationToken);
}
catch
{
return Result.Error($"An error occurred while uploading picture for playlist {playList.Id} to storage.");
}
playList.OriginalPictureName = pictureName;
return Result.Success();
}
@@ -12,7 +12,7 @@ using Musify.Domain.Entities;
namespace Musify.Application.PlayLists.Handlers
{
public class UpdatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageHandler storageHandler,
public class UpdatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageService storageHandler,
ILogger<UpdatePlayListCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IRequestHandler<UpdatePlayListCommand, Task<Result<PlayListResponse>> >
@@ -80,19 +80,20 @@ namespace Musify.Application.PlayLists.Handlers
var pictureName = Guid.NewGuid() + newPicture.FileType;
var pictureKey = Path.Combine(playListConfiguration.Routes.OriginalPicturesPath, pictureName);
var uploadResult = await storageHandler.UploadFileAsync(
newPicture.FileStream,
newPicture.ContentType,
storageConfiguration.BucketName,
pictureKey,
cancellationToken);
if (!uploadResult.IsSuccess)
try
{
logger.LogError("Failed to upload picture for playlist {PlayListId} to storage: {ErrorMessage}",
playList.Id, string.Join("; ", uploadResult.Errors));
return Result.Error($"Failed to upload playlist {playList.Id} picture");
await storageHandler.UploadFileAsync(
newPicture.FileStream,
newPicture.ContentType,
storageConfiguration.BucketName,
pictureKey,
cancellationToken);
}
catch
{
return Result.Error("Failed to upload picture to storage");
}
playList.OriginalPictureName = pictureName;
return Result.Success(pictureKey);
}
@@ -10,7 +10,7 @@ using Musify.Domain.Entities;
namespace Musify.Application.Tracks.Handler
{
public class CreateTrackCommandHandler(IDatabase database, IEventBus eventBus, IStorageHandler storageHandler,
public class CreateTrackCommandHandler(IDatabase database, IEventBus eventBus, IStorageService storageHandler,
ILogger<CreateTrackCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration)
: IRequestHandler<CreateTrackCommand, Task<Result<TrackResponse> >>
{
@@ -49,33 +49,34 @@ namespace Musify.Application.Tracks.Handler
string audioKey,
CancellationToken cancellationToken)
{
var pictureUpload = await storageHandler.UploadFileAsync(
request.Picture.FileStream,
request.Picture.ContentType,
storageConfiguration.BucketName,
pictureKey,
cancellationToken);
if (!pictureUpload.IsSuccess)
try
{
await storageHandler.UploadFileAsync(
request.Picture.FileStream,
request.Picture.ContentType,
storageConfiguration.BucketName,
pictureKey,
cancellationToken);
}
catch
{
var errors = string.Join(", ", pictureUpload.Errors);
return Result.Error($"Failed to upload picture for {request.Title}");
}
var audioUpload = await storageHandler.UploadFileAsync(
request.Audio.FileStream,
request.Audio.ContentType,
storageConfiguration.BucketName,
audioKey,
cancellationToken);
if (!audioUpload.IsSuccess)
try
{
await storageHandler.UploadFileAsync(
request.Audio.FileStream,
request.Audio.ContentType,
storageConfiguration.BucketName,
audioKey,
cancellationToken);
}
catch
{
await RollbackFilesAsync(pictureKey, audioKey, cancellationToken);
var errors = string.Join(", ", audioUpload.Errors);
return Result.Error($"Failed to upload audio for {request.Title}");
}
return Result.Success();
}
@@ -8,7 +8,7 @@ using Musify.Domain.Entities;
namespace Musify.Application.Tracks.Handler
{
public class DeleteTrackCommandHandler(IDatabase database, IStorageHandler storageHandler, ILogger<DeleteTrackCommandHandler> logger,
public class DeleteTrackCommandHandler(IDatabase database, IStorageService storageHandler, ILogger<DeleteTrackCommandHandler> logger,
TrackConfiguration trackConfiguration, ApplicationStorageConfiguration storageConfiguration)
: IRequestHandler<DeleteTrackCommand, Task<Result>>
{
@@ -20,10 +20,13 @@ namespace Musify.Application.Users.Handlers
return Result<UserResponse>.Conflict($"User {request.Id} already exists");
}
var keycloakUser = await keycloakUserClient.GetUserByIdAsync(request.Id.ToString(), cancellationToken);
if (!keycloakUser.IsSuccess)
KeycloakUserResponse keycloakUser;
try
{
keycloakUser = await keycloakUserClient.GetUserByIdAsync(request.Id.ToString(), cancellationToken);
}
catch
{
logger.LogWarning("Keycloak user {UserId} not found", request.Id);
return Result.NotFound("Keycloak user not found");
}
@@ -34,9 +37,9 @@ namespace Musify.Application.Users.Handlers
logger.LogInformation("Created user {UserId}", newUser.Id);
return Result.Created(new UserResponse(
newUser.Id.ToString(),
keycloakUser.Value.Name,
keycloakUser.Value.FirstName,
keycloakUser.Value.SecondName,
keycloakUser.Name,
keycloakUser.FirstName,
keycloakUser.SecondName,
newUser.CreatedAt,
newUser.UpdatedAt));
}
@@ -18,15 +18,21 @@ namespace Musify.Application.Users.Handlers
if (user is null)
return Result.NotFound("User not found");
var keycloakUser = await keycloakUserClient.GetUserByIdAsync(user.Id.ToString(), cancellationToken);
if (!keycloakUser.IsSuccess)
return Result.NotFound("Keycloak user not found");
KeycloakUserResponse keycloakUser;
try
{
keycloakUser = await keycloakUserClient.GetUserByIdAsync(user.Id.ToString(), cancellationToken);
}
catch
{
return Result.Error("Keycloak user not found");
}
return Result.Success(new UserResponse(
user.Id.ToString(),
keycloakUser.Value.Name,
keycloakUser.Value.FirstName,
keycloakUser.Value.SecondName,
keycloakUser.Name,
keycloakUser.FirstName,
keycloakUser.SecondName,
user.CreatedAt,
user.UpdatedAt));
}
@@ -18,15 +18,21 @@ namespace Musify.Application.Users.Handlers
if (user is null)
return Result.NotFound("User not found");
var keycloakUser = await keycloakUserClient.GetUserByIdAsync(user.Id.ToString(), cancellationToken);
if (!keycloakUser.IsSuccess)
KeycloakUserResponse keycloakUser;
try
{
keycloakUser = await keycloakUserClient.GetUserByIdAsync(user.Id.ToString(), cancellationToken);
}
catch
{
return Result.NotFound("Keycloak user not found");
}
return Result.Success(new UserResponse(
user.Id.ToString(),
keycloakUser.Value.Name,
keycloakUser.Value.FirstName,
keycloakUser.Value.SecondName,
keycloakUser.Name,
keycloakUser.FirstName,
keycloakUser.SecondName,
user.CreatedAt,
user.UpdatedAt));
}
@@ -1,14 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Contracts.Infrastructure;
namespace Musify.Infrastructure.Audio
{
public static class AudioTranscoderDependencyInjection
{
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors)
{
serviceDescriptors.AddScoped<IAudioTranscoder, AudioTranscoder>();
return serviceDescriptors;
}
}
}
@@ -1,40 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Musify.Infrastructure.Configuration
{
public static class ConfigurationDependencyInjection
{
public static IServiceCollection AddInfrastructureConfigurations(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddValidatedConfiguration<DatabaseConfiguration>(configuration, DatabaseConfiguration.SectionName)
.AddValidatedConfiguration<KeycloakConfiguration>(configuration, KeycloakConfiguration.SectionName)
.AddValidatedConfiguration<InfrastructureStorageConfiguration>(configuration, InfrastructureStorageConfiguration.SectionName)
.AddValidatedConfiguration<MessagingConfiguration>(configuration, MessagingConfiguration.SectionName)
.AddValidatedConfiguration<OpenTelemetryConfiguration>(configuration, OpenTelemetryConfiguration.SectionName)
.AddValidatedConfiguration<AudioTranscoderConfiguration>(configuration, AudioTranscoderConfiguration.SectionName)
.AddValidatedConfiguration<WorkerConfiguration>(configuration, WorkerConfiguration.SectionName);
return serviceDescriptors;
}
static IServiceCollection AddValidatedConfiguration<TConfiguration>(
this IServiceCollection serviceDescriptors,
IConfiguration configuration,
string sectionName)
where TConfiguration : class
{
serviceDescriptors
.AddOptionsWithValidateOnStart<TConfiguration>()
.Bind(configuration.GetRequiredSection(sectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<TConfiguration>>().Value);
return serviceDescriptors;
}
}
}
@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
namespace Musify.Infrastructure.Configuration
{
public class MessagingConfiguration
public class MassTransitConfiguration
{
public const string SectionName = "Messaging";
@@ -1,30 +0,0 @@
using Keycloak.Net;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Contracts.Infrastructure;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Identity
{
public static class KeycloakDependencyInjection
{
public static IServiceCollection AddKeycloakService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddOptionsWithValidateOnStart<KeycloakConfiguration>()
.Bind(configuration.GetRequiredSection(KeycloakConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddScoped(serviceProvider =>
{
var keycloakConfiguration = serviceProvider.GetRequiredService<KeycloakConfiguration>();
return new KeycloakClient(
keycloakConfiguration.BaseUrl,
keycloakConfiguration.Username,
keycloakConfiguration.Password);
} );
serviceDescriptors.AddScoped<IKeycloakUserService, KeycloakUserService>();
return serviceDescriptors;
}
}
}
@@ -1,17 +0,0 @@
using Keycloak.Net.Models.Users;
using Musify.Application.Users.Responses;
namespace Musify.Infrastructure.Identity
{
public static class KeycloakUserMapper
{
public static KeycloakUserResponse Map(User keycloakUser)
{
return new KeycloakUserResponse(
Guid.Parse(keycloakUser.Id ?? throw new InvalidOperationException("Keycloak user Id is required.")),
keycloakUser.UserName ?? throw new InvalidOperationException("Keycloak user Name is required."),
keycloakUser.FirstName ?? throw new InvalidOperationException("Keycloak user FirstName is required."),
keycloakUser.LastName ?? throw new InvalidOperationException("Keycloak user SecondName is required."));
}
}
}
@@ -1,50 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Contracts.Infrastructure;
using Musify.Infrastructure.Audio;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Identity;
using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Observability;
using Musify.Infrastructure.Persistence;
using Musify.Infrastructure.Pictures;
using Musify.Infrastructure.Storage;
namespace Musify.Infrastructure
{
public static class InfrastructureDependencyInjection
{
public static IServiceCollection AddInfrastructureModules(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddInfrastructureConfigurations(configuration)
.AddDatabase(configuration)
.AddKeycloakService(configuration)
.AddStorageHandler()
.AddPictureHandler()
.AddAudioTranscoder()
.AddInfrastructureOpenTelemetry()
.AddMassTransitClient()
.AddScoped<IEventBus, MassTransitEventBus>()
.AddScoped<IProcessTrackingStore, ProcessTrackingStore>();
return serviceDescriptors;
}
public static IServiceCollection AddInfrastructureWorkerModules(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddInfrastructureConfigurations(configuration)
.AddDatabase(configuration)
.AddStorageHandler()
.AddPictureHandler()
.AddAudioTranscoder()
.AddInfrastructureOpenTelemetry()
.AddMassTransitConsumers()
.AddScoped<IEventBus, MassTransitEventBus>()
.AddScoped<IProcessTrackingStore, ProcessTrackingStore>();
return serviceDescriptors;
}
}
}
@@ -8,7 +8,7 @@ using Musify.Infrastructure.MassTransit.Activities.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class TranscodeDashAudioActivity(
IAudioTranscoder audioTranscoder,
IAudioTranscoderService audioTranscoder,
ILogger<TranscodeDashAudioActivity> logger,
IProcessTrackingStore processTrackingStore)
: IActivity<TranscodeDashAudioArguments, TranscodeDashAudioLog>
@@ -40,18 +40,10 @@ namespace Musify.Infrastructure.MassTransit.Activities
{
await using (var fileStream = File.OpenRead(sourceFilePath))
{
var result = await audioTranscoder.TranscodeToDashAsync(
await audioTranscoder.TranscodeToDashAsync(
fileStream,
workingDirectory,
executeContext.CancellationToken);
if (!result.IsSuccess)
{
var errorMessage = string.Join("; ", result.Errors);
logger.LogError("Transcoding failed for {SourceFilePath}: {Errors}",
sourceFilePath, errorMessage);
throw new Exception(errorMessage);
}
}
File.Delete(sourceFilePath);
@@ -8,7 +8,7 @@ using Musify.Infrastructure.MassTransit.Activities.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class DownloadFileFromBucketActivity(
IStorageHandler storageHandler,
IStorageService storageHandler,
ILogger<DownloadFileFromBucketActivity> logger,
IProcessTrackingStore processTrackingStore)
: IActivity<DownloadFileFromBucketArguments, DownloadFileFromBucketLog>
@@ -36,26 +36,15 @@ namespace Musify.Infrastructure.MassTransit.Activities
try
{
var getFile = await storageHandler.GetFileAsync(
using var fileStream = await storageHandler.GetFileAsync(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
executeContext.CancellationToken);
if (!getFile.IsSuccess)
{
var errorMessage = string.Join("; ", getFile.Errors);
logger.LogWarning("Failed to download {Bucket}/{Key}: {Errors}",
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
errorMessage);
throw new Exception(errorMessage);
}
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
using var fileStream = getFile.Value;
if (fileStream.CanSeek)
fileStream.Position = 0;
@@ -7,7 +7,7 @@ using Musify.Infrastructure.MassTransit.Activities.Arguments;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class RemoveFileFromBucketActivity(
IStorageHandler storageHandler,
IStorageService storageHandler,
ILogger<RemoveFileFromBucketActivity> logger,
IProcessTrackingStore processTrackingStore)
: IExecuteActivity<RemoveFileFromBucketArguments>
@@ -32,20 +32,11 @@ namespace Musify.Infrastructure.MassTransit.Activities
try
{
var removeFile = await storageHandler.RemoveFileAsync(
await storageHandler.RemoveFileAsync(
executeContext.Arguments.Bucket,
executeContext.Arguments.Key,
executeContext.CancellationToken);
if (!removeFile.IsSuccess)
{
var errorMessage = string.Join("; ", removeFile.Errors);
logger.LogWarning("Failed to remove {Key} from bucket {Bucket}: {Errors}",
executeContext.Arguments.Key,
executeContext.Arguments.Bucket,
errorMessage);
}
await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken);
return executeContext.Completed();
}
@@ -8,7 +8,7 @@ using Musify.Infrastructure.MassTransit.Activities.Logs;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class TransferFilesToBucketActivity(
IStorageHandler storageHandler,
IStorageService storageHandler,
ILogger<TransferFilesToBucketActivity> logger,
IProcessTrackingStore processTrackingStore)
: IActivity<TransferFilesToBucketArguments, TransferFilesToBucketLog>
@@ -42,23 +42,12 @@ namespace Musify.Infrastructure.MassTransit.Activities
.Select(file => Path.Combine(destinationKey, Path.GetFileName(file)))
.ToArray();
var transferFilesResult = await storageHandler.TransferFilesAsync(
await storageHandler.TransferFilesAsync(
folderPath,
executeContext.Arguments.DestinationBucket,
destinationKey,
executeContext.CancellationToken);
if (!transferFilesResult.IsSuccess)
{
var errorMessage = string.Join("; ", transferFilesResult.Errors);
logger.LogWarning("Failed to transfer files from {FolderPath} to {DestinationBucket}/{DestinationKey}: {Errors}",
folderPath,
executeContext.Arguments.DestinationBucket,
destinationKey,
errorMessage);
throw new Exception(errorMessage);
}
await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken);
return executeContext.Completed();
}
@@ -8,7 +8,7 @@ using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files
{
public class UploadFileToBucketActivity(IStorageHandler storageHandler,
public class UploadFileToBucketActivity(IStorageService storageHandler,
ILogger<UploadFileToBucketActivity> logger,
IProcessTrackingStore processTrackingStore)
: IActivity<UploadFileToBucketArguments, UploadFileToBucketLog>
@@ -37,22 +37,12 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files
try
{
var uploadResult = await storageHandler.UploadFileAsync(
await storageHandler.UploadFileAsync(
sourceFilePath,
executeContext.Arguments.DestinationBucket,
Path.Combine(executeContext.Arguments.DestinationRoute, fileName),
executeContext.CancellationToken);
if (!uploadResult.IsSuccess)
{
var errors = string.Join("; ", uploadResult.Errors);
logger.LogWarning("Failed to upload file to bucket {Bucket}/{Route}: {Errors}",
executeContext.Arguments.DestinationBucket,
executeContext.Arguments.DestinationRoute,
errors);
throw new Exception(errors);
}
await processTrackingStore.CompleteStepAsync(processId, stepId, executeContext.CancellationToken);
return executeContext.CompletedWithVariables(new UploadFileToBucketLog(
executeContext.Arguments.DestinationBucket,
@@ -70,19 +60,10 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files
{
try
{
var uploadResult = await storageHandler.RemoveFileAsync(
await storageHandler.RemoveFileAsync(
compensateContext.Log.DestinationBucket,
compensateContext.Log.DestinationKey,
compensateContext.CancellationToken);
if (!uploadResult.IsSuccess)
{
var errors = string.Join("; ", uploadResult.Errors);
logger.LogError("Failed to remove file from bucket during compensation. Bucket: {Bucket}, Key: {Key}, Errors: {Errors}",
compensateContext.Log.DestinationBucket, compensateContext.Log.DestinationKey, errors);
return compensateContext.Failed(new Exception($"Failed to remove file from bucket during compensation. Bucket: {compensateContext.Log.DestinationBucket}, Key: {compensateContext.Log.DestinationKey}, Errors: {errors}"));
}
return compensateContext.Compensated();
}
catch (Exception exception)
@@ -9,7 +9,7 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
namespace Musify.Infrastructure.MassTransit.Activities
{
public class ResizePictureActivity(
IPictureHandler pictureHandler,
IPictureService pictureHandler,
ILogger<ResizePictureActivity> logger,
IProcessTrackingStore processTrackingStore)
: IActivity<ResizePictureLocalArguments, ResizePictureLog>
@@ -53,23 +53,12 @@ namespace Musify.Infrastructure.MassTransit.Activities
executeContext.Arguments.Height,
executeContext.CancellationToken);
if (!resizedPicture.IsSuccess)
{
var errorMessage = string.Join("; ", resizedPicture.Errors);
logger.LogError("Failed to resize picture {SourceFilePath} to {Width}x{Height}: {Errors}",
sourceFilePath,
executeContext.Arguments.Width,
executeContext.Arguments.Height,
errorMessage);
throw new Exception(errorMessage);
}
var destinationDirectory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrEmpty(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
await using var destinationStream = File.Create(destinationFilePath);
await resizedPicture.Value.CopyToAsync(destinationStream, executeContext.CancellationToken);
await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken);
logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}",
sourceFilePath,
@@ -23,7 +23,7 @@ namespace Musify.Infrastructure.MassTransit
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MessagingConfiguration>()));
busRegistrationContext.GetRequiredService<MassTransitConfiguration>()));
} );
return serviceDescriptors;
@@ -60,7 +60,7 @@ namespace Musify.Infrastructure.MassTransit
{
ConfigureRabbitMqHost(
busFactoryConfigurator,
busRegistrationContext.GetRequiredService<MessagingConfiguration>());
busRegistrationContext.GetRequiredService<MassTransitConfiguration>());
busFactoryConfigurator.UseConsumeFilter(typeof(ProcessTrackingConsumeFilter<>), busRegistrationContext);
@@ -141,7 +141,7 @@ namespace Musify.Infrastructure.MassTransit
static void ConfigureRabbitMqHost(
IRabbitMqBusFactoryConfigurator busFactoryConfigurator,
MessagingConfiguration massTransitConfiguration)
MassTransitConfiguration massTransitConfiguration)
{
busFactoryConfigurator.Host(massTransitConfiguration.Host, options =>
{
@@ -1,6 +1,8 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Musify.Application.Contracts.Infrastructure;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Persistence
{
@@ -8,6 +10,14 @@ namespace Musify.Infrastructure.Persistence
{
public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<DatabaseConfiguration>()
.Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddDbContext<Database>();
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
@@ -1,14 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Contracts.Infrastructure;
namespace Musify.Infrastructure.Pictures
{
public static class PictureDependencyInjection
{
public static IServiceCollection AddPictureHandler(this IServiceCollection serviceDescriptors)
{
serviceDescriptors.AddScoped<IPictureHandler, PictureHandler>();
return serviceDescriptors;
}
}
}
@@ -1,17 +1,18 @@
using Ardalis.Result;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts.Infrastructure;
using Musify.Infrastructure.Configuration;
using System.Diagnostics;
namespace Musify.Infrastructure.Audio
namespace Musify.Infrastructure.Services
{
public class AudioTranscoder(
ILogger<AudioTranscoder> logger,
public class AudioTranscoderService(
ILogger<AudioTranscoderService> logger,
AudioTranscoderConfiguration audioTranscoderConfiguration)
: IAudioTranscoder
: IAudioTranscoderService
{
public async Task<Result> TranscodeToDashAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken)
record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError);
public async Task<int> TranscodeToDashAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken)
{
try
{
@@ -30,30 +31,32 @@ namespace Musify.Infrastructure.Audio
{
logger.LogError("Audio transcoding to DASH failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}",
executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError);
return Result.Error($"Audio transcoding failed (ExitCode: {executionResult.ExitCode})");
}
else
{
logger.LogInformation("Audio transcoding to DASH completed for {DestinationPath}", destinationPath);
}
logger.LogInformation("Audio transcoding to DASH completed for {DestinationPath}", destinationPath);
return Result.Success();
return executionResult.ExitCode;
}
catch (TimeoutException timeoutException)
{
logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioTranscoderConfiguration.TranscodingTimeout, destinationPath);
return Result.Error("Audio transcoding timed out");
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to transcode audio to DASH for {DestinationPath}", destinationPath);
return Result.Error(exception.Message);
throw;
}
}
public async Task<Result<bool>> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken)
public async Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken)
{
if (!File.Exists(filePath))
{
logger.LogWarning("Audio validation requested for non-existing file {FilePath}", filePath);
return Result<bool>.Error($"Audio file not found: {filePath}");
return false;
}
try
@@ -76,18 +79,17 @@ namespace Musify.Infrastructure.Audio
logger.LogDebug("Audio validation successful for {FilePath}", filePath);
}
return Result<bool>.Success(isValid);
return isValid;
}
catch (TimeoutException timeoutException)
{
logger.LogError(timeoutException, "Audio validation timed out for {FilePath}", filePath);
return Result<bool>.Error("Audio validation timed out.");
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to validate audio file {FilePath}", filePath);
return Result<bool>.Error(exception.Message);
}
return false;
}
async Task<FfmpegExecutionResult> ExecuteFfmpegWithInputAsync(
@@ -216,7 +218,5 @@ namespace Musify.Infrastructure.Audio
static string BuildValidateAudioArguments(string filePath)
=> $"-v error -i \"{filePath}\" -map 0:a:0 -f null -";
sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError);
}
}
@@ -1,4 +1,3 @@
using Ardalis.Result;
using Keycloak.Net;
using Keycloak.Net.Models.Users;
using Microsoft.Extensions.Logging;
@@ -6,14 +5,14 @@ using Musify.Application.Contracts.Infrastructure;
using Musify.Application.Users.Responses;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Identity
namespace Musify.Infrastructure.Services
{
public class KeycloakUserService(
KeycloakClient keycloakClient,
KeycloakConfiguration keycloakConfiguration,
ILogger<KeycloakUserService> logger) : IKeycloakUserService
{
public async Task<Result<KeycloakUserResponse>> GetUserByIdAsync(string keycloakId, CancellationToken cancellationToken)
public async Task<KeycloakUserResponse> GetUserByIdAsync(string keycloakId, CancellationToken cancellationToken)
{
User keycloakUser;
@@ -24,14 +23,14 @@ namespace Musify.Infrastructure.Identity
catch (OperationCanceledException)
{
logger.LogWarning("Operation to retrieve Keycloak user {KeycloakId} was cancelled", keycloakId);
return Result.Error("Operation cancelled");
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to retrieve Keycloak user {KeycloakId}", keycloakId);
return Result.NotFound();
throw;
}
return Result.Success(KeycloakUserMapper.Map(keycloakUser));
return ToResponse(keycloakUser);
}
public async Task<IReadOnlyCollection<KeycloakUserResponse>> GetUsersAsync(CancellationToken cancellationToken, string search = "", int first = 0, int max = 20, string username = "")
@@ -45,7 +44,7 @@ namespace Musify.Infrastructure.Identity
cancellationToken: cancellationToken);
return users
.Select(KeycloakUserMapper.Map)
.Select(ToResponse)
.ToArray();
}
@@ -57,5 +56,14 @@ namespace Musify.Infrastructure.Identity
username: username,
cancellationToken: cancellationToken);
}
KeycloakUserResponse ToResponse(User keycloakUser)
{
return new KeycloakUserResponse(
Guid.Parse(keycloakUser.Id ?? throw new InvalidOperationException("Keycloak user Id is required.")),
keycloakUser.UserName ?? throw new InvalidOperationException("Keycloak user Name is required."),
keycloakUser.FirstName ?? throw new InvalidOperationException("Keycloak user FirstName is required."),
keycloakUser.LastName ?? throw new InvalidOperationException("Keycloak user SecondName is required."));
}
}
}
@@ -1,15 +1,14 @@
using Ardalis.Result;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts.Infrastructure;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Musify.Infrastructure.Pictures
namespace Musify.Infrastructure.Services
{
public class PictureHandler(ILogger<PictureHandler> logger)
: IPictureHandler
public class PictureService(ILogger<PictureService> logger)
: IPictureService
{
public async Task<Result<Stream>> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken)
public async Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken)
{
try
{
@@ -18,7 +17,6 @@ namespace Musify.Infrastructure.Pictures
pictureStream.Position = 0;
using var picture = await Image.LoadAsync(pictureStream, cancellationToken);
picture.Mutate(options => options.Resize(new ResizeOptions
{
Size = new Size(width, height),
@@ -26,18 +24,16 @@ namespace Musify.Infrastructure.Pictures
} ));
var memoryStream = new MemoryStream();
await picture.SaveAsWebpAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;
logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length);
return Result<Stream>.Success(memoryStream);
return memoryStream;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to resize picture to {Width}x{Height}", width, height);
return Result.Error("Failed to resize picture");
throw;
}
}
}
@@ -0,0 +1,81 @@
using Amazon.S3;
using Keycloak.Net;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Musify.Application.Contracts.Infrastructure;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Services
{
public static class ServicesDependencyInjection
{
public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<InfrastructureStorageConfiguration>()
.Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<InfrastructureStorageConfiguration>>().Value);
serviceDescriptors.AddScoped<IAmazonS3>(serviceProvider =>
{
var configuration = serviceProvider.GetRequiredService<InfrastructureStorageConfiguration>();
var s3Configuration = new AmazonS3Config
{
ServiceURL = configuration.ServiceUrl,
ForcePathStyle = configuration.ForcePathStyle,
UseHttp = configuration.UseHttp,
};
return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration);
} );
serviceDescriptors.AddScoped<IStorageService, StorageService>();
return serviceDescriptors;
}
public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddScoped<IPictureService, PictureService>();
return serviceDescriptors;
}
public static IServiceCollection AddKeycloakService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors.AddOptionsWithValidateOnStart<KeycloakConfiguration>()
.Bind(configuration.GetRequiredSection(KeycloakConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<KeycloakConfiguration>>().Value);
serviceDescriptors.AddScoped(serviceProvider =>
{
var keycloakConfiguration = serviceProvider.GetRequiredService<KeycloakConfiguration>();
return new KeycloakClient(
keycloakConfiguration.BaseUrl,
keycloakConfiguration.Username,
keycloakConfiguration.Password);
} );
serviceDescriptors.AddScoped<IKeycloakUserService, KeycloakUserService>();
return serviceDescriptors;
}
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<AudioTranscoderConfiguration>()
.Bind(configuration.GetRequiredSection(AudioTranscoderConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<AudioTranscoderConfiguration>>().Value);
serviceDescriptors.AddScoped<IAudioTranscoderService, AudioTranscoderService>();
return serviceDescriptors;
}
}
}
@@ -1,19 +1,18 @@
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using Ardalis.Result;
using Microsoft.Extensions.Logging;
using MimeMapping;
using Musify.Application.Contracts.Infrastructure;
using Musify.Domain.Entities;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Storage
namespace Musify.Infrastructure.Services
{
public class StorageHandler(IDatabase database, IAmazonS3 amazonS3, InfrastructureStorageConfiguration storageClientConfiguration, ILogger<StorageHandler> logger)
: IStorageHandler
public class StorageService(IDatabase database, IAmazonS3 amazonS3, InfrastructureStorageConfiguration storageClientConfiguration, ILogger<StorageService> logger)
: IStorageService
{
public async Task<Result<Stream>> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
public async Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new GetObjectRequest
{
@@ -24,14 +23,13 @@ namespace Musify.Infrastructure.Storage
try
{
var response = await amazonS3.GetObjectAsync(request, cancellationToken);
logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key);
return Result.Success(response.ResponseStream);
return response.ResponseStream;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);
return Result.NotFound($"File {bucket}/{key} not found");
logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);
throw;
}
}
@@ -46,12 +44,10 @@ namespace Musify.Infrastructure.Storage
};
logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key);
var url = await amazonS3.GetPreSignedURLAsync(request);
return url;
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task<Result> RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken)
public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new DeleteObjectRequest
{
@@ -60,12 +56,10 @@ namespace Musify.Infrastructure.Storage
};
logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key);
var response = await amazonS3.DeleteObjectAsync(request, cancellationToken);
return Result.NoContent();
await amazonS3.DeleteObjectAsync(request, cancellationToken);
}
public async Task<Result> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken)
public async Task TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken)
{
var trasnsferUtility = new TransferUtility(amazonS3);
@@ -101,31 +95,30 @@ namespace Musify.Infrastructure.Storage
failedCount++;
upload.State = UploadState.Failed;
logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key);
throw;
}
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount);
return Result.Success();
}
public async Task<Result<string>> UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken)
public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken)
{
try
{
using var fileStream = File.OpenRead(filePath);
var contentType = MimeUtility.GetMimeMapping(filePath);
return await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken);
await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key);
return Result.Error($"Failed to upload to {bucket}/{key}");
throw;
}
}
public async Task<Result<string>> UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken)
public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken)
{
var request = new PutObjectRequest
{
@@ -153,17 +146,16 @@ namespace Musify.Infrastructure.Storage
{
logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key);
upload.State = UploadState.Failed;
return Result.Error($"Failed to upload to {bucket}/{key}");
await database.SaveChangesAsync(cancellationToken);
throw;
}
upload.State = UploadState.Successful;
await database.SaveChangesAsync(cancellationToken);
logger.LogDebug("Saved upload record for {Bucket}/{Key}", bucket, key);
return Result.Success(key);
}
public async Task<Result> CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken)
{
var request = new CopyObjectRequest
@@ -179,13 +171,12 @@ namespace Musify.Infrastructure.Storage
var response = await amazonS3.CopyObjectAsync(request, cancellationToken);
logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}",
sourceBucket, sourceKey, destinationBucket, destinationKey);
return Result.Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to copy file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}",
sourceBucket, sourceKey, destinationBucket, destinationKey);
return Result.Error($"Failed to copy from {sourceBucket}/{sourceKey} to {destinationBucket}/{destinationKey}");
throw;
}
}
}
@@ -1,28 +0,0 @@
using Amazon.S3;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Contracts.Infrastructure;
using Musify.Infrastructure.Configuration;
namespace Musify.Infrastructure.Storage
{
public static class StorageDependencyInjection
{
public static IServiceCollection AddStorageHandler(this IServiceCollection serviceDescriptors)
{
serviceDescriptors.AddScoped<IAmazonS3>(serviceProvider =>
{
var configuration = serviceProvider.GetRequiredService<InfrastructureStorageConfiguration>();
var s3Configuration = new AmazonS3Config
{
ServiceURL = configuration.ServiceUrl,
ForcePathStyle = configuration.ForcePathStyle,
UseHttp = configuration.UseHttp,
};
return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration);
} );
serviceDescriptors.AddScoped<IStorageHandler, StorageHandler>();
return serviceDescriptors;
}
}
}