Experimental changes

This commit is contained in:
2026-03-29 17:15:39 +02:00
parent c6f919ee89
commit 697eed4e58
35 changed files with 279 additions and 445 deletions
@@ -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;
}
}
}