Remove dead code: unused storage/transcoder members, Upload table, retry columns, redundant packages

This commit is contained in:
2026-09-24 05:19:59 +02:00
parent 0e3576922f
commit 5e819d7676
41 changed files with 1230 additions and 570 deletions
@@ -55,9 +55,6 @@ public class UpdateAlbumCommandHandler(
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
@@ -66,13 +63,10 @@ public class UpdateAlbumCommandHandler(
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
var pictureIntent = validation.Value;
album.Pictures = AlbumPictures.Pending(pictureIntent.ObjectName);
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
@@ -3,6 +3,4 @@ namespace Musify.Application.Contracts;
public interface IAudioTranscoderService
{
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
public Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
}
@@ -24,8 +24,6 @@ public interface IDatabase
public DbSet<MixItem> MixItems { get; }
public DbSet<Upload> Uploads { get; }
public DbSet<UploadIntent> UploadIntents { get; }
public DbSet<ListeningHistory> ListeningHistories { get; }
@@ -6,8 +6,6 @@ public interface IStorageService
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true);
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
@@ -21,7 +19,5 @@ public interface IStorageService
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken);
public Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
@@ -63,9 +63,6 @@ public class UpdatePlayListCommandHandler(
playListEntity.Visibility = request.NewVisibility.Value;
}
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
@@ -74,13 +71,10 @@ public class UpdatePlayListCommandHandler(
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
var pictureIntent = validation.Value;
playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName);
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
@@ -17,8 +17,7 @@ public class GetUsersQueryHandler(IDatabase database)
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{
var usersQuery = database.Users
.AsNoTracking()
.AsQueryable();
.AsNoTracking();
if (!string.IsNullOrEmpty(request.UsernameSearch))
{
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Musify.Domain.ValueObjects;
namespace Musify.Domain.Entities;
[Table("Upload")]
public class Upload
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required string Bucket { get; set; }
[Required]
public required string Key { get; set; }
[Required]
public required string ContentType { get; set; }
[Required]
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
[Required]
public UploadState State { get; set; } = UploadState.Pending;
}
@@ -11,12 +11,6 @@ public class TrackAudio
public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending;
public bool DownloadRequested { get; set; }
public int RetryCount { get; set; }
public DateTime? LastRetryAt { get; set; }
[MemberNotNullWhen(true, nameof(FolderName))]
public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed;
@@ -1,8 +0,0 @@
namespace Musify.Domain.ValueObjects;
public enum UploadState
{
Pending,
Failed,
Successful,
}
@@ -14,16 +14,18 @@
<PackageReference Include="MassTransit.EntityFrameworkCore" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="MimeMapping" />
<!-- Hangfire pulls in a vulnerable Newtonsoft.Json; pin the patched version for every host. -->
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
@@ -54,20 +54,17 @@ public class UploadIntentExpirationJob(
logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count);
}
if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0)
var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays);
var oldExpiredIntents = await database.UploadIntents
.Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff)
.ToListAsync(cancellationToken);
if (oldExpiredIntents.Count > 0)
{
var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays);
var oldExpiredIntents = await database.UploadIntents
.Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff)
.ToListAsync(cancellationToken);
if (oldExpiredIntents.Count > 0)
{
database.UploadIntents.RemoveRange(oldExpiredIntents);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count);
}
database.UploadIntents.RemoveRange(oldExpiredIntents);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count);
}
}
}
@@ -1,68 +0,0 @@
using MassTransit;
using Microsoft.Extensions.Logging;
using Musify.Infrastructure.MassTransit.Arguments;
using Musify.Infrastructure.MassTransit.Logs;
namespace Musify.Infrastructure.MassTransit.Activities.Files;
internal class DownloadFileFromUrlActivity(
IHttpClientFactory httpClientFactory,
ILogger<DownloadFileFromUrlActivity> logger)
: IActivity<DownloadFileFromUrlArguments, DownloadFileFromUrlLog>
{
public const string ExecuteEndpointName = "download-file-from-url";
public async Task<ExecutionResult> Execute(ExecuteContext<DownloadFileFromUrlArguments> executeContext)
{
var destinationPath = executeContext.GetVariable<string>(executeContext.Arguments.DestinationFilePathVariable);
ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath));
try
{
var httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(
executeContext.Arguments.SourceUrl,
HttpCompletionOption.ResponseHeadersRead,
executeContext.CancellationToken);
response.EnsureSuccessStatusCode();
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
Directory.CreateDirectory(destinationDirectory);
await using (var destinationStream = File.Create(destinationPath))
{
await response.Content.CopyToAsync(destinationStream, executeContext.CancellationToken);
}
logger.LogInformation("Downloaded {SourceUrl} to {DestinationPath}",
executeContext.Arguments.SourceUrl, destinationPath);
return executeContext.Completed(new DownloadFileFromUrlLog(
executeContext.Arguments.SourceUrl,
destinationPath));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to download file from {SourceUrl}", executeContext.Arguments.SourceUrl);
throw;
}
}
public Task<CompensationResult> Compensate(CompensateContext<DownloadFileFromUrlLog> compensateContext)
{
try
{
if (File.Exists(compensateContext.Log.DestinationFilePath))
File.Delete(compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Compensated());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}",
compensateContext.Log.DestinationFilePath);
return Task.FromResult(compensateContext.Failed(exception));
}
}
}
@@ -57,7 +57,6 @@ internal class UpdateAlbumPictureActivity(
album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable);
album.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
database.Albums.Update(album);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated album {AlbumId} pictures",
@@ -98,7 +97,6 @@ internal class UpdateAlbumPictureActivity(
LargeName = compensateContext.Log.PreviousLargePictureKey
};
database.Albums.Update(album);
await database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
@@ -57,7 +57,6 @@ internal class UpdatePlayListPictureActivity(
playList.Pictures.MediumName = Path.GetFileName(mediumResizedVariable);
playList.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
database.PlayLists.Update(playList);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated playlist {PlayListId} pictures",
@@ -98,7 +97,6 @@ internal class UpdatePlayListPictureActivity(
LargeName = compensateContext.Log.PreviousLargePictureKey
};
database.PlayLists.Update(playList);
await database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
@@ -51,7 +51,6 @@ internal class UpdateTrackPictureActivity(
track.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
track.Pictures.ProcessingStatus = ProcessingStatus.Completed;
database.Tracks.Update(track);
await database.SaveChangesAsync(executeContext.CancellationToken);
logger.LogInformation("Updated track {TrackId} pictures",
@@ -88,7 +87,6 @@ internal class UpdateTrackPictureActivity(
track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey;
track.Pictures.ProcessingStatus = ProcessingStatus.Failed;
database.Tracks.Update(track);
await database.SaveChangesAsync(compensateContext.CancellationToken);
return compensateContext.Compensated();
@@ -1,5 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Arguments;
public record DownloadFileFromUrlArguments(
string SourceUrl,
string DestinationFilePathVariable);
@@ -75,6 +75,5 @@ public static partial class MassTransitDependencyInjection
options.AddActivity<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>();
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>();
options.AddActivity<DownloadFileFromUrlActivity, DownloadFileFromUrlArguments, DownloadFileFromUrlLog>();
}
}
@@ -1,5 +0,0 @@
namespace Musify.Infrastructure.MassTransit.Logs;
public record DownloadFileFromUrlLog(
string SourceUrl,
string DestinationFilePath);
@@ -16,7 +16,6 @@ internal static class ActivityNames
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
internal const string DownloadFile = "DownloadFile";
internal const string DownloadThumbnail = "DownloadThumbnail";
internal const string TranscodeAudio = "TranscodeAudio";
@@ -8,12 +8,11 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Sagas;
using Npgsql;
using AppIDatabase = Musify.Application.Contracts.IDatabase;
namespace Musify.Infrastructure.Persistence;
public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase
: DbContext, Musify.Application.Contracts.IDatabase
{
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
@@ -54,9 +53,6 @@ public class Database(DatabaseConfiguration configuration)
audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64);
audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64);
audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus");
audio.Property(a => a.DownloadRequested).HasColumnName("DownloadRequested");
audio.Property(a => a.RetryCount).HasColumnName("RetryCount");
audio.Property(a => a.LastRetryAt).HasColumnName("LastRetryAt");
});
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
@@ -193,8 +189,6 @@ public class Database(DatabaseConfiguration configuration)
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<Upload> Uploads => Set<Upload>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Musify.Infrastructure.Configuration;
@@ -21,9 +20,6 @@ public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory<Database>
.Get<DatabaseConfiguration>()
?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found.");
var optionsBuilder = new DbContextOptionsBuilder<Database>();
optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString);
return new Database(databaseConfiguration);
}
}
@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class DropUploadTableAndTrackAudioRetryColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Upload");
migrationBuilder.DropColumn(
name: "DownloadRequested",
table: "Tracks");
migrationBuilder.DropColumn(
name: "LastRetryAt",
table: "Tracks");
migrationBuilder.DropColumn(
name: "RetryCount",
table: "Tracks");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DownloadRequested",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "LastRetryAt",
table: "Tracks",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RetryCount",
table: "Tracks",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Upload",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Bucket = table.Column<string>(type: "text", nullable: false),
ContentType = table.Column<string>(type: "text", nullable: false),
CreatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Key = table.Column<string>(type: "text", nullable: false),
State = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Upload", x => x.Id);
});
}
}
}
@@ -511,35 +511,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("TrackTags");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.Property<Guid>("Id")
@@ -970,28 +941,16 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.Property<Guid>("TrackId")
.HasColumnType("uuid");
b1.Property<bool>("DownloadRequested")
.HasColumnType("boolean")
.HasColumnName("DownloadRequested");
b1.Property<string>("FolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("AudioFolderName");
b1.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("LastRetryAt");
b1.Property<string>("OriginalName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalAudioName");
b1.Property<int>("RetryCount")
.HasColumnType("integer")
.HasColumnName("RetryCount");
b1.Property<int>("TranscodeStatus")
.HasColumnType("integer")
.HasColumnName("AudioTranscodeProcessingStatus");
@@ -75,47 +75,6 @@ public partial class AudioService(ILogger<AudioService> logger,
return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds);
}
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 false;
}
try
{
logger.LogDebug("Validating audio file {FilePath}", filePath);
var executionResult = await ExecuteFfmpegAsync(
BuildValidateAudioArguments(filePath),
Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory,
"ValidateAudioFile",
cancellationToken);
var isValid = executionResult.ExitCode == 0;
if (!isValid)
{
logger.LogWarning("Audio validation failed for {FilePath}: {StandardError}", filePath, executionResult.StandardError);
}
else
{
logger.LogDebug("Audio validation successful for {FilePath}", filePath);
}
return isValid;
}
catch (TimeoutException timeoutException)
{
logger.LogError(timeoutException, "Audio validation timed out for {FilePath}", filePath);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to validate audio file {FilePath}", filePath);
}
return false;
}
private async Task<FfmpegExecutionResult> ExecuteFfmpegWithInputAsync(
string arguments,
string workingDirectory,
@@ -239,9 +198,6 @@ public partial class AudioService(ILogger<AudioService> logger,
$" \"{outputPath}\"";
}
private static string BuildValidateAudioArguments(string filePath)
=> $"-v error -i \"{filePath}\" -map 0:a:0 -f null -";
private static string BuildProbeDurationArguments(string filePath)
=> $"-hide_banner -i \"{filePath}\" -f null -";
}
@@ -39,7 +39,7 @@ public static class ServicesDependencyInjection
return serviceDescriptors;
}
public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors)
{
serviceDescriptors.AddScoped<IPictureService, PictureService>();
return serviceDescriptors;
@@ -1,40 +0,0 @@
using System.Collections.Concurrent;
using ErrorOr;
using Microsoft.Extensions.Caching.Memory;
namespace Musify.Infrastructure.Services;
public sealed class SingleFlightCache(IMemoryCache cache)
{
private readonly ConcurrentDictionary<string, object> inFlight = new();
public async Task<ErrorOr<TValue>> GetOrCreateAsync<TValue>(
string key,
TimeSpan duration,
Func<Task<ErrorOr<TValue>>> factory)
{
if (duration <= TimeSpan.Zero)
return await factory();
if (cache.TryGetValue(key, out TValue? cached) && cached != null)
return cached;
var pending = (Lazy<Task<ErrorOr<TValue>>>)inFlight.GetOrAdd(
key,
_ => new Lazy<Task<ErrorOr<TValue>>>(factory));
try
{
var result = await pending.Value;
if (!result.IsError)
cache.Set(key, result.Value, duration);
return result;
}
finally
{
inFlight.TryRemove(key, out _);
}
}
}
@@ -4,14 +4,12 @@ using Amazon.S3.Transfer;
using Microsoft.Extensions.Logging;
using MimeMapping;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Configuration;
using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData;
namespace Musify.Infrastructure.Services;
public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<StorageService> logger,
public class StorageService(IAmazonS3 amazonS3, ILogger<StorageService> logger,
InfrastructureStorageConfiguration storageClientConfiguration)
: IStorageService
{
@@ -41,20 +39,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
}
}
public async Task<string> GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken)
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Expires = DateTime.UtcNow + expirationTime,
Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS
};
logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key);
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task<string> GetUploadUrlAsync(
string bucket,
string key,
@@ -129,15 +113,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
var contentType = MimeUtility.GetMimeMapping(filePath);
var upload = new Upload
{
Id = Guid.NewGuid(),
Bucket = bucket,
Key = key,
ContentType = contentType
};
await database.Uploads.AddAsync(upload, cancellationToken);
try
{
await trasnsferUtility.UploadAsync(
@@ -149,7 +124,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
ContentType = contentType
},
cancellationToken);
upload.State = UploadState.Successful;
uploadedKeys.Add(key);
successCount++;
logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key);
@@ -157,13 +131,11 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
catch (Exception exception)
{
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 uploadedKeys;
@@ -194,31 +166,16 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
Key = key
};
var upload = new Upload
{
Id = Guid.NewGuid(),
Bucket = bucket,
Key = key,
ContentType = contentType
};
await database.Uploads.AddAsync(upload, cancellationToken);
try
{
var response = await amazonS3.PutObjectAsync(request, cancellationToken);
await amazonS3.PutObjectAsync(request, cancellationToken);
logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key);
upload.State = UploadState.Failed;
await database.SaveChangesAsync(cancellationToken);
throw;
}
upload.State = UploadState.Successful;
await database.SaveChangesAsync(cancellationToken);
logger.LogDebug("Saved upload record for {Bucket}/{Key}", bucket, key);
}
public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
@@ -234,7 +191,7 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
try
{
var response = await amazonS3.CopyObjectAsync(request, cancellationToken);
await amazonS3.CopyObjectAsync(request, cancellationToken);
logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}",
sourceBucket, sourceKey, destinationBucket, destinationKey);
}
@@ -266,43 +223,4 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
}
while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value);
}
public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken)
{
var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/";
var listRequest = new ListObjectsV2Request
{
BucketName = bucket,
Prefix = prefix
};
ListObjectsV2Response listResponse;
do
{
listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken);
if (listResponse.S3Objects == null || listResponse.S3Objects.Count == 0)
break;
var deleteRequest = new DeleteObjectsRequest
{
BucketName = bucket,
Objects = listResponse.S3Objects
.Select(o => new KeyVersion { Key = o.Key })
.ToList()
};
var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken);
var deleteErrors = deleteResponse.DeleteErrors ?? [];
if (deleteErrors.Count > 0)
{
var errors = string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}"));
throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}");
}
listRequest.ContinuationToken = listResponse.NextContinuationToken;
} while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value);
}
}
-17
View File
@@ -8,29 +8,12 @@
<ItemGroup>
<PackageReference Include="ErrorOr" />
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="MassTransit" />
<PackageReference Include="MassTransit.EntityFrameworkCore" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Mediator.Abstractions" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Api" />
<PackageReference Include="OpenTelemetry.Api.ProviderBuilderExtensions" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
@@ -7,7 +7,6 @@ public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
{
private CurrentUser(ClaimsPrincipal principal)
{
Principal = principal;
IsAuthenticated = principal.Identity?.IsAuthenticated ?? false;
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -19,8 +18,6 @@ public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
LastName = principal.FindFirstValue(ClaimTypes.Surname);
}
public ClaimsPrincipal Principal { get; }
public bool IsAuthenticated { get; }
public long? Id { get; }
@@ -33,10 +30,6 @@ public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid.");
public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type);
public string? FindClaim(string type) => Principal.FindFirstValue(type);
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
}
-18
View File
@@ -116,19 +116,6 @@
"LargePictureHeight": 512
}
},
"AudioTranscoder": {
"TranscodingTimeout": "00:05:00",
"Ffmpeg": {
"ExecutableName": "ffmpeg",
"OutputFileName": "audio.m4a",
"AudioCodec": "aac",
"AudioBitrate": "128k",
"AudioChannels": 2,
"AudioSampleRate": 48000,
"AudioProfile": "aac_low",
"AdditionalArguments": ""
}
},
"UploadIntent": {
"UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5,
@@ -141,10 +128,5 @@
"TempRootPrefix": "temp",
"TempUploadsRetentionDays": 3,
"TempCleanupJobIntervalSeconds": 3600
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": ""
}
}
}
+1 -2
View File
@@ -23,8 +23,7 @@ services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfigurati
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddStorageService(configuration);
services.AddAudioTranscoder(configuration);
services.AddHttpClient();
services.AddPictureService(configuration);
services.AddPictureService();
services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration);
services.AddUploadIntentJobs(configuration);
@@ -7,17 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="MassTransit" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="SixLabors.ImageSharp" />
</ItemGroup>
<ItemGroup>
@@ -70,9 +70,6 @@
}
},
"AudioTranscoder": {
"Routes": {
"WorkingDirectory": ""
},
"TranscodingTimeout": "00:05:00"
},
"Workers": {
@@ -63,22 +63,4 @@ public sealed class CurrentUserTests
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
}
[Fact]
public async Task HasClaim_ExistingClaimType_ReturnsTrue()
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
Assert.True(user.HasClaim("role"));
Assert.False(user.HasClaim("missing"));
}
[Fact]
public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
Assert.Null(user.FindClaim("missing"));
}
}
@@ -151,8 +151,6 @@ public sealed class TestDatabase : DbContext, IDatabase
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<Upload> Uploads => Set<Upload>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
@@ -1,80 +0,0 @@
using ErrorOr;
using Microsoft.Extensions.Caching.Memory;
using Musify.Infrastructure.Services;
using Xunit;
namespace Musify.Infrastructure.Tests.Services;
public sealed class SingleFlightCacheTests
{
private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions()));
[Fact]
public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
{
var cache = CreateCache();
var calls = 0;
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
Assert.Equal(1, first.Value);
Assert.Equal(1, second.Value);
Assert.Equal(1, calls);
}
[Fact]
public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall()
{
var cache = CreateCache();
var calls = 0;
var gate = new TaskCompletionSource();
async Task<ErrorOr<int>> Factory()
{
Interlocked.Increment(ref calls);
await gate.Task;
return 7;
}
var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
gate.SetResult();
var results = await Task.WhenAll(first, second);
Assert.Equal(1, calls);
Assert.All(results, result => Assert.Equal(7, result.Value));
}
[Fact]
public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt()
{
var cache = CreateCache();
var calls = 0;
Task<ErrorOr<int>> Factory()
{
calls++;
return Task.FromResult<ErrorOr<int>>(Error.Failure(description: "boom"));
}
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
Assert.Equal(2, calls);
}
[Fact]
public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory()
{
var cache = CreateCache();
var calls = 0;
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
Assert.Equal(2, calls);
}
}
@@ -3,7 +3,6 @@ using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Persistence;
using Musify.Infrastructure.Services;
using Musify.Infrastructure.Tests.TestSupport;
using Xunit;
@@ -13,12 +12,10 @@ namespace Musify.Infrastructure.Tests.Services;
[Collection(InfrastructureCollection.Name)]
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
{
private Database database = null!;
private StorageService service = null!;
public ValueTask InitializeAsync()
{
database = fixture.CreateDatabase();
var storageConfiguration = new InfrastructureStorageConfiguration
{
Address = fixture.S3ServiceUrl,
@@ -27,11 +24,11 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs
ForcePathStyle = true,
UseHttp = true,
};
service = new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
service = new StorageService(fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => database.DisposeAsync();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
@@ -114,21 +111,6 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
}
[Fact]
public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix()
{
var prefix = $"tests/{Guid.NewGuid():N}";
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", TestContext.Current.CancellationToken);
await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken);
var remaining = new List<string>();
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
remaining.Add(item.Key);
Assert.Empty(remaining);
}
[Fact]
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
{