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.Description = request.Description;
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
album.ReleaseYear = request.ReleaseYear;
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
|
||||||
string? finalPictureKey = null;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId.HasValue)
|
if (request.NewPictureIntentId.HasValue)
|
||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
@@ -66,13 +63,10 @@ public class UpdateAlbumCommandHandler(
|
|||||||
if (validation.IsError)
|
if (validation.IsError)
|
||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
var pictureIntent = validation.Value;
|
||||||
album.Pictures = AlbumPictures.Pending(pictureIntent.ObjectName);
|
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(
|
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
|
||||||
album.Id, pictureIntent, finalPictureKey, cancellationToken);
|
album.Id, pictureIntent, finalPictureKey, cancellationToken);
|
||||||
if (publishResult.IsError)
|
if (publishResult.IsError)
|
||||||
|
|||||||
@@ -3,6 +3,4 @@ namespace Musify.Application.Contracts;
|
|||||||
public interface IAudioTranscoderService
|
public interface IAudioTranscoderService
|
||||||
{
|
{
|
||||||
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
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<MixItem> MixItems { get; }
|
||||||
|
|
||||||
public DbSet<Upload> Uploads { get; }
|
|
||||||
|
|
||||||
public DbSet<UploadIntent> UploadIntents { get; }
|
public DbSet<UploadIntent> UploadIntents { get; }
|
||||||
|
|
||||||
public DbSet<ListeningHistory> ListeningHistories { 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<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<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);
|
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,
|
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ public class UpdatePlayListCommandHandler(
|
|||||||
playListEntity.Visibility = request.NewVisibility.Value;
|
playListEntity.Visibility = request.NewVisibility.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
|
||||||
string? finalPictureKey = null;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId.HasValue)
|
if (request.NewPictureIntentId.HasValue)
|
||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
@@ -74,13 +71,10 @@ public class UpdatePlayListCommandHandler(
|
|||||||
if (validation.IsError)
|
if (validation.IsError)
|
||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
var pictureIntent = validation.Value;
|
||||||
playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName);
|
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(
|
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
|
||||||
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
|
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
|
||||||
if (publishResult.IsError)
|
if (publishResult.IsError)
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ public class GetUsersQueryHandler(IDatabase database)
|
|||||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var usersQuery = database.Users
|
var usersQuery = database.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking();
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.UsernameSearch))
|
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 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))]
|
[MemberNotNullWhen(true, nameof(FolderName))]
|
||||||
public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed;
|
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.EntityFrameworkCore" />
|
||||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
|
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
|
||||||
<PackageReference Include="MimeMapping" />
|
<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="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ public class UploadIntentExpirationJob(
|
|||||||
logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count);
|
logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0)
|
|
||||||
{
|
|
||||||
var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays);
|
var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays);
|
||||||
|
|
||||||
var oldExpiredIntents = await database.UploadIntents
|
var oldExpiredIntents = await database.UploadIntents
|
||||||
@@ -69,5 +67,4 @@ public class UploadIntentExpirationJob(
|
|||||||
logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count);
|
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.MediumName = Path.GetFileName(mediumResizedVariable);
|
||||||
album.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
|
album.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
|
||||||
|
|
||||||
database.Albums.Update(album);
|
|
||||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Updated album {AlbumId} pictures",
|
logger.LogInformation("Updated album {AlbumId} pictures",
|
||||||
@@ -98,7 +97,6 @@ internal class UpdateAlbumPictureActivity(
|
|||||||
LargeName = compensateContext.Log.PreviousLargePictureKey
|
LargeName = compensateContext.Log.PreviousLargePictureKey
|
||||||
};
|
};
|
||||||
|
|
||||||
database.Albums.Update(album);
|
|
||||||
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
||||||
|
|
||||||
return compensateContext.Compensated();
|
return compensateContext.Compensated();
|
||||||
|
|||||||
-2
@@ -57,7 +57,6 @@ internal class UpdatePlayListPictureActivity(
|
|||||||
playList.Pictures.MediumName = Path.GetFileName(mediumResizedVariable);
|
playList.Pictures.MediumName = Path.GetFileName(mediumResizedVariable);
|
||||||
playList.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
|
playList.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
|
||||||
|
|
||||||
database.PlayLists.Update(playList);
|
|
||||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Updated playlist {PlayListId} pictures",
|
logger.LogInformation("Updated playlist {PlayListId} pictures",
|
||||||
@@ -98,7 +97,6 @@ internal class UpdatePlayListPictureActivity(
|
|||||||
LargeName = compensateContext.Log.PreviousLargePictureKey
|
LargeName = compensateContext.Log.PreviousLargePictureKey
|
||||||
};
|
};
|
||||||
|
|
||||||
database.PlayLists.Update(playList);
|
|
||||||
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
||||||
|
|
||||||
return compensateContext.Compensated();
|
return compensateContext.Compensated();
|
||||||
|
|||||||
-2
@@ -51,7 +51,6 @@ internal class UpdateTrackPictureActivity(
|
|||||||
track.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
|
track.Pictures.LargeName = Path.GetFileName(largeResizedVariable);
|
||||||
track.Pictures.ProcessingStatus = ProcessingStatus.Completed;
|
track.Pictures.ProcessingStatus = ProcessingStatus.Completed;
|
||||||
|
|
||||||
database.Tracks.Update(track);
|
|
||||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Updated track {TrackId} pictures",
|
logger.LogInformation("Updated track {TrackId} pictures",
|
||||||
@@ -88,7 +87,6 @@ internal class UpdateTrackPictureActivity(
|
|||||||
track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey;
|
track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey;
|
||||||
track.Pictures.ProcessingStatus = ProcessingStatus.Failed;
|
track.Pictures.ProcessingStatus = ProcessingStatus.Failed;
|
||||||
|
|
||||||
database.Tracks.Update(track);
|
|
||||||
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
await database.SaveChangesAsync(compensateContext.CancellationToken);
|
||||||
|
|
||||||
return compensateContext.Compensated();
|
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<UploadFileToBucketActivity, UploadFileToBucketArguments, UploadFileToBucketLog>();
|
||||||
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
|
options.AddActivity<CopyFileInBucketActivity, CopyFileInBucketArguments, CopyFileInBucketLog>();
|
||||||
options.AddActivity<ConsumeUploadIntentsActivity, ConsumeUploadIntentsArguments, ConsumeUploadIntentsLog>();
|
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 GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
|
||||||
|
|
||||||
internal const string DownloadFile = "DownloadFile";
|
internal const string DownloadFile = "DownloadFile";
|
||||||
internal const string DownloadThumbnail = "DownloadThumbnail";
|
|
||||||
|
|
||||||
internal const string TranscodeAudio = "TranscodeAudio";
|
internal const string TranscodeAudio = "TranscodeAudio";
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ using Musify.Domain.ValueObjects;
|
|||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
using Musify.Infrastructure.MassTransit.Sagas;
|
using Musify.Infrastructure.MassTransit.Sagas;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using AppIDatabase = Musify.Application.Contracts.IDatabase;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Persistence;
|
namespace Musify.Infrastructure.Persistence;
|
||||||
|
|
||||||
public class Database(DatabaseConfiguration configuration)
|
public class Database(DatabaseConfiguration configuration)
|
||||||
: DbContext, AppIDatabase
|
: DbContext, Musify.Application.Contracts.IDatabase
|
||||||
{
|
{
|
||||||
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
|
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.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64);
|
||||||
audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64);
|
audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64);
|
||||||
audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus");
|
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();
|
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<MixItem> MixItems => Set<MixItem>();
|
||||||
|
|
||||||
public DbSet<Upload> Uploads => Set<Upload>();
|
|
||||||
|
|
||||||
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
||||||
|
|
||||||
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
@@ -21,9 +20,6 @@ public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory<Database>
|
|||||||
.Get<DatabaseConfiguration>()
|
.Get<DatabaseConfiguration>()
|
||||||
?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found.");
|
?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found.");
|
||||||
|
|
||||||
var optionsBuilder = new DbContextOptionsBuilder<Database>();
|
|
||||||
optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString);
|
|
||||||
|
|
||||||
return new Database(databaseConfiguration);
|
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");
|
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 =>
|
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -970,28 +941,16 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b1.Property<Guid>("TrackId")
|
b1.Property<Guid>("TrackId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b1.Property<bool>("DownloadRequested")
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasColumnName("DownloadRequested");
|
|
||||||
|
|
||||||
b1.Property<string>("FolderName")
|
b1.Property<string>("FolderName")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("AudioFolderName");
|
.HasColumnName("AudioFolderName");
|
||||||
|
|
||||||
b1.Property<DateTime?>("LastRetryAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("LastRetryAt");
|
|
||||||
|
|
||||||
b1.Property<string>("OriginalName")
|
b1.Property<string>("OriginalName")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("OriginalAudioName");
|
.HasColumnName("OriginalAudioName");
|
||||||
|
|
||||||
b1.Property<int>("RetryCount")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("RetryCount");
|
|
||||||
|
|
||||||
b1.Property<int>("TranscodeStatus")
|
b1.Property<int>("TranscodeStatus")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("AudioTranscodeProcessingStatus");
|
.HasColumnName("AudioTranscodeProcessingStatus");
|
||||||
|
|||||||
@@ -75,47 +75,6 @@ public partial class AudioService(ILogger<AudioService> logger,
|
|||||||
return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds);
|
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(
|
private async Task<FfmpegExecutionResult> ExecuteFfmpegWithInputAsync(
|
||||||
string arguments,
|
string arguments,
|
||||||
string workingDirectory,
|
string workingDirectory,
|
||||||
@@ -239,9 +198,6 @@ public partial class AudioService(ILogger<AudioService> logger,
|
|||||||
$" \"{outputPath}\"";
|
$" \"{outputPath}\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildValidateAudioArguments(string filePath)
|
|
||||||
=> $"-v error -i \"{filePath}\" -map 0:a:0 -f null -";
|
|
||||||
|
|
||||||
private static string BuildProbeDurationArguments(string filePath)
|
private static string BuildProbeDurationArguments(string filePath)
|
||||||
=> $"-hide_banner -i \"{filePath}\" -f null -";
|
=> $"-hide_banner -i \"{filePath}\" -f null -";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public static class ServicesDependencyInjection
|
|||||||
return serviceDescriptors;
|
return serviceDescriptors;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors)
|
||||||
{
|
{
|
||||||
serviceDescriptors.AddScoped<IPictureService, PictureService>();
|
serviceDescriptors.AddScoped<IPictureService, PictureService>();
|
||||||
return serviceDescriptors;
|
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 Microsoft.Extensions.Logging;
|
||||||
using MimeMapping;
|
using MimeMapping;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData;
|
using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Services;
|
namespace Musify.Infrastructure.Services;
|
||||||
|
|
||||||
public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<StorageService> logger,
|
public class StorageService(IAmazonS3 amazonS3, ILogger<StorageService> logger,
|
||||||
InfrastructureStorageConfiguration storageClientConfiguration)
|
InfrastructureStorageConfiguration storageClientConfiguration)
|
||||||
: IStorageService
|
: 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(
|
public async Task<string> GetUploadUrlAsync(
|
||||||
string bucket,
|
string bucket,
|
||||||
string key,
|
string key,
|
||||||
@@ -129,15 +113,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
|||||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||||
.Select(static s => s.Trim().Trim('/', '\\')));
|
.Select(static s => s.Trim().Trim('/', '\\')));
|
||||||
var contentType = MimeUtility.GetMimeMapping(filePath);
|
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
|
try
|
||||||
{
|
{
|
||||||
await trasnsferUtility.UploadAsync(
|
await trasnsferUtility.UploadAsync(
|
||||||
@@ -149,7 +124,6 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
|||||||
ContentType = contentType
|
ContentType = contentType
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
upload.State = UploadState.Successful;
|
|
||||||
uploadedKeys.Add(key);
|
uploadedKeys.Add(key);
|
||||||
successCount++;
|
successCount++;
|
||||||
logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key);
|
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)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
failedCount++;
|
failedCount++;
|
||||||
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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 uploadedKeys;
|
return uploadedKeys;
|
||||||
@@ -194,31 +166,16 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
|||||||
Key = key
|
Key = key
|
||||||
};
|
};
|
||||||
|
|
||||||
var upload = new Upload
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Bucket = bucket,
|
|
||||||
Key = key,
|
|
||||||
ContentType = contentType
|
|
||||||
};
|
|
||||||
await database.Uploads.AddAsync(upload, cancellationToken);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await amazonS3.PutObjectAsync(request, cancellationToken);
|
await amazonS3.PutObjectAsync(request, cancellationToken);
|
||||||
logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key);
|
logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key);
|
||||||
}
|
}
|
||||||
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);
|
||||||
upload.State = UploadState.Failed;
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
throw;
|
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,
|
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
|
try
|
||||||
{
|
{
|
||||||
var response = await amazonS3.CopyObjectAsync(request, cancellationToken);
|
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);
|
||||||
}
|
}
|
||||||
@@ -266,43 +223,4 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
|||||||
}
|
}
|
||||||
while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value);
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="ErrorOr" />
|
<PackageReference Include="ErrorOr" />
|
||||||
<PackageReference Include="AWSSDK.S3" />
|
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
<PackageReference Include="MassTransit" />
|
|
||||||
<PackageReference Include="MassTransit.EntityFrameworkCore" />
|
|
||||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
|
||||||
<PackageReference Include="Mediator.Abstractions" />
|
<PackageReference Include="Mediator.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
|
||||||
<PackageReference Include="Microsoft.OpenApi" />
|
<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="Scalar.AspNetCore" />
|
||||||
<PackageReference Include="X.PagedList.EF" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
|
|||||||
{
|
{
|
||||||
private CurrentUser(ClaimsPrincipal principal)
|
private CurrentUser(ClaimsPrincipal principal)
|
||||||
{
|
{
|
||||||
Principal = principal;
|
|
||||||
IsAuthenticated = principal.Identity?.IsAuthenticated ?? false;
|
IsAuthenticated = principal.Identity?.IsAuthenticated ?? false;
|
||||||
|
|
||||||
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
@@ -19,8 +18,6 @@ public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
|
|||||||
LastName = principal.FindFirstValue(ClaimTypes.Surname);
|
LastName = principal.FindFirstValue(ClaimTypes.Surname);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClaimsPrincipal Principal { get; }
|
|
||||||
|
|
||||||
public bool IsAuthenticated { get; }
|
public bool IsAuthenticated { get; }
|
||||||
|
|
||||||
public long? Id { 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 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) =>
|
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
|
||||||
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
|
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,19 +116,6 @@
|
|||||||
"LargePictureHeight": 512
|
"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": {
|
"UploadIntent": {
|
||||||
"UploadUrlExpiresInSeconds": 120,
|
"UploadUrlExpiresInSeconds": 120,
|
||||||
"MaxActiveUploadIntentsPerUser": 5,
|
"MaxActiveUploadIntentsPerUser": 5,
|
||||||
@@ -141,10 +128,5 @@
|
|||||||
"TempRootPrefix": "temp",
|
"TempRootPrefix": "temp",
|
||||||
"TempUploadsRetentionDays": 3,
|
"TempUploadsRetentionDays": 3,
|
||||||
"TempCleanupJobIntervalSeconds": 3600
|
"TempCleanupJobIntervalSeconds": 3600
|
||||||
},
|
|
||||||
"Workers": {
|
|
||||||
"Routes": {
|
|
||||||
"TemporaryFilesDirectory": ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfigurati
|
|||||||
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
||||||
services.AddStorageService(configuration);
|
services.AddStorageService(configuration);
|
||||||
services.AddAudioTranscoder(configuration);
|
services.AddAudioTranscoder(configuration);
|
||||||
services.AddHttpClient();
|
services.AddPictureService();
|
||||||
services.AddPictureService(configuration);
|
|
||||||
services.AddDatabase(configuration);
|
services.AddDatabase(configuration);
|
||||||
services.AddMassTransitConsumers(configuration);
|
services.AddMassTransitConsumers(configuration);
|
||||||
services.AddUploadIntentJobs(configuration);
|
services.AddUploadIntentJobs(configuration);
|
||||||
|
|||||||
@@ -7,17 +7,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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.Hosting" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||||
<PackageReference Include="MimeMapping" />
|
|
||||||
<PackageReference Include="Newtonsoft.Json" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
|
||||||
<PackageReference Include="SixLabors.ImageSharp" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -70,9 +70,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AudioTranscoder": {
|
"AudioTranscoder": {
|
||||||
"Routes": {
|
|
||||||
"WorkingDirectory": ""
|
|
||||||
},
|
|
||||||
"TranscodingTimeout": "00:05:00"
|
"TranscodingTimeout": "00:05:00"
|
||||||
},
|
},
|
||||||
"Workers": {
|
"Workers": {
|
||||||
|
|||||||
@@ -63,22 +63,4 @@ public sealed class CurrentUserTests
|
|||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
|
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<MixItem> MixItems => Set<MixItem>();
|
||||||
|
|
||||||
public DbSet<Upload> Uploads => Set<Upload>();
|
|
||||||
|
|
||||||
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
||||||
|
|
||||||
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
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 System.Text;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
using Musify.Infrastructure.Persistence;
|
|
||||||
using Musify.Infrastructure.Services;
|
using Musify.Infrastructure.Services;
|
||||||
using Musify.Infrastructure.Tests.TestSupport;
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -13,12 +12,10 @@ namespace Musify.Infrastructure.Tests.Services;
|
|||||||
[Collection(InfrastructureCollection.Name)]
|
[Collection(InfrastructureCollection.Name)]
|
||||||
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
|
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private Database database = null!;
|
|
||||||
private StorageService service = null!;
|
private StorageService service = null!;
|
||||||
|
|
||||||
public ValueTask InitializeAsync()
|
public ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
database = fixture.CreateDatabase();
|
|
||||||
var storageConfiguration = new InfrastructureStorageConfiguration
|
var storageConfiguration = new InfrastructureStorageConfiguration
|
||||||
{
|
{
|
||||||
Address = fixture.S3ServiceUrl,
|
Address = fixture.S3ServiceUrl,
|
||||||
@@ -27,11 +24,11 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs
|
|||||||
ForcePathStyle = true,
|
ForcePathStyle = true,
|
||||||
UseHttp = 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;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => database.DisposeAsync();
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
|
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());
|
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]
|
[Fact]
|
||||||
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
|
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -315,7 +315,6 @@ services:
|
|||||||
ASPNETCORE_URLS: http://+:5111
|
ASPNETCORE_URLS: http://+:5111
|
||||||
StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL}
|
StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL}
|
||||||
StreamTicket__PrivateKeyPath: /keys/stream_private.pem
|
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.
|
# AUTH_CLIENT_ID is written to the env file by the zitadel-init service.
|
||||||
Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration"
|
Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration"
|
||||||
Authentication__IssuerAddress: ${PUBLIC_AUTH_URL}
|
Authentication__IssuerAddress: ${PUBLIC_AUTH_URL}
|
||||||
@@ -344,7 +343,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *backend-env
|
<<: *backend-env
|
||||||
DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
||||||
AudioTranscoder__Routes__WorkingDirectory: /tmp/musify
|
|
||||||
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
|
Workers__Routes__TemporaryFilesDirectory: /tmp/musify
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
@@ -52,4 +52,3 @@ See [authentication.md](authentication.md).
|
|||||||
- **PlayListHasTrack**: the tracks inside a playlist (with `Position`).
|
- **PlayListHasTrack**: the tracks inside a playlist (with `Position`).
|
||||||
- **UserHasTrack**: ownership, which user owns which track.
|
- **UserHasTrack**: ownership, which user owns which track.
|
||||||
- **UploadIntent**: an upload reservation (key, bucket, expected size, status, expiration) used for validation and quota.
|
- **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.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.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.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 |
|
| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user