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