Remove dead code: unused storage/transcoder members, Upload table, retry columns, redundant packages
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-68
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
-2
@@ -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();
|
||||
|
||||
-2
@@ -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();
|
||||
|
||||
-2
@@ -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();
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
public record DownloadFileFromUrlArguments(
|
||||
string SourceUrl,
|
||||
string DestinationFilePathVariable);
|
||||
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1133
File diff suppressed because it is too large
Load Diff
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -315,7 +315,6 @@ services:
|
||||
ASPNETCORE_URLS: http://+:5111
|
||||
StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL}
|
||||
StreamTicket__PrivateKeyPath: /keys/stream_private.pem
|
||||
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
|
||||
# AUTH_CLIENT_ID is written to the env file by the zitadel-init service.
|
||||
Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration"
|
||||
Authentication__IssuerAddress: ${PUBLIC_AUTH_URL}
|
||||
@@ -344,7 +343,6 @@ services:
|
||||
environment:
|
||||
<<: *backend-env
|
||||
DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
||||
AudioTranscoder__Routes__WorkingDirectory: /tmp/musify
|
||||
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -52,4 +52,3 @@ See [authentication.md](authentication.md).
|
||||
- **PlayListHasTrack**: the tracks inside a playlist (with `Position`).
|
||||
- **UserHasTrack**: ownership, which user owns which track.
|
||||
- **UploadIntent**: an upload reservation (key, bucket, expected size, status, expiration) used for validation and quota.
|
||||
- **Upload**: a record of objects uploaded or transferred to storage.
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ dotnet test backend/Musify.slnx
|
||||
|---|---|---|
|
||||
| `Musify.Domain.Tests` | Value object logic (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Entities are mostly anemic, with no behavior of their own beyond that. | No |
|
||||
| `Musify.Application.Tests` | The ~45 handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No |
|
||||
| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Yes** |
|
||||
| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `PlayListPresetSeeder`. | **Yes** |
|
||||
| `Musify.Api.Tests` | Real HTTP endpoints (routing, auth, `ValidationFilter`, `ErrorOr`→HTTP mapping) via `WebApplicationFactory`, plus `ErrorOrHttpExtensions`/`CurrentUser`/FluentValidation validators in isolation. | **Yes** |
|
||||
| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user