chore: import MusifyBackend into monorepo (preserving history)
git-subtree-dir: MusifyBackend git-subtree-mainline:6829e769edgit-subtree-split:ed7b00c2cc
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Musify.Application</RootNamespace>
|
||||
<PackageId>Musify.Application</PackageId>
|
||||
<Version>0.1.2</Version>
|
||||
<Authors>NotS1mp</Authors>
|
||||
<RepositoryUrl>https://github.com/XShine555/MusifyBackend</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Ardalis.Result" Version="10.1.0" />
|
||||
<PackageReference Include="MassTransit" Version="8.5.9" />
|
||||
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
|
||||
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.8" />
|
||||
<PackageReference Include="X.PagedList" Version="10.5.9" />
|
||||
<PackageReference Include="X.PagedList.EF" Version="10.5.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public class ApplicationStorageConfiguration
|
||||
{
|
||||
public const string SectionName = "ApplicationStorage";
|
||||
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 1)]
|
||||
public required string Bucket { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static class ConfigurationDependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplicationStorageConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||
{
|
||||
serviceDescriptors
|
||||
.AddOptionsWithValidateOnStart<ApplicationStorageConfiguration>()
|
||||
.Bind(configuration.GetRequiredSection(ApplicationStorageConfiguration.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<ApplicationStorageConfiguration>>().Value);
|
||||
|
||||
return serviceDescriptors;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPlayListConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||
{
|
||||
serviceDescriptors
|
||||
.AddOptionsWithValidateOnStart<PlayListConfiguration>()
|
||||
.Bind(configuration.GetRequiredSection(PlayListConfiguration.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<PlayListConfiguration>>().Value);
|
||||
|
||||
return serviceDescriptors;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddTrackConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||
{
|
||||
serviceDescriptors
|
||||
.AddOptionsWithValidateOnStart<TrackConfiguration>()
|
||||
.Bind(configuration.GetRequiredSection(TrackConfiguration.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<TrackConfiguration>>().Value);
|
||||
|
||||
return serviceDescriptors;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddStreamGatewayConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||
{
|
||||
serviceDescriptors
|
||||
.AddOptionsWithValidateOnStart<StreamGatewayConfiguration>()
|
||||
.Bind(configuration.GetRequiredSection(StreamGatewayConfiguration.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<StreamGatewayConfiguration>>().Value);
|
||||
|
||||
return serviceDescriptors;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public class PlayListConfiguration
|
||||
{
|
||||
public const string SectionName = "PlayList";
|
||||
|
||||
[Required]
|
||||
public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
|
||||
|
||||
[Required]
|
||||
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
||||
}
|
||||
|
||||
public class PlayListRoutes
|
||||
{
|
||||
[Required]
|
||||
public string UploadsFolder { get; set; } = "uploads";
|
||||
|
||||
[Required]
|
||||
public string ParentFolders { get; set; } = "PlayLists";
|
||||
|
||||
[Required]
|
||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
||||
|
||||
[Required]
|
||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
||||
|
||||
[Required]
|
||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
||||
|
||||
[Required]
|
||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||
|
||||
[Required]
|
||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
||||
|
||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
||||
|
||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
||||
|
||||
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
|
||||
|
||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, PresetSmallPicture);
|
||||
|
||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, PresetMediumPicture);
|
||||
|
||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, PresetLargePicture);
|
||||
|
||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
||||
|
||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
||||
|
||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
||||
|
||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
|
||||
|
||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
|
||||
|
||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
|
||||
|
||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
|
||||
}
|
||||
|
||||
public class PlayListPicturesSizes
|
||||
{
|
||||
[Range(1, 1024)]
|
||||
public int SmallPictureWidth { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int SmallPictureHeight { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumPictureWidth { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumPictureHeight { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargePictureWidth { get; set; } = 512;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargePictureHeight { get; set; } = 512;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public class StreamGatewayConfiguration
|
||||
{
|
||||
public const string SectionName = "StreamGateway";
|
||||
|
||||
[Url]
|
||||
[Required]
|
||||
public required string PublicBaseUrl { get; set; }
|
||||
|
||||
[Required]
|
||||
public string AudioFileName { get; set; } = "audio.m4a";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public class TrackConfiguration
|
||||
{
|
||||
public const string SectionName = "Track";
|
||||
|
||||
[Required]
|
||||
public TrackRoutes Routes { get; set; } = new TrackRoutes();
|
||||
|
||||
[Required]
|
||||
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
|
||||
}
|
||||
|
||||
public class TrackRoutes
|
||||
{
|
||||
[Required]
|
||||
public string UploadsFolder { get; set; } = "uploads";
|
||||
|
||||
[Required]
|
||||
public string ParentFolder { get; set; } = "Tracks";
|
||||
|
||||
[Required]
|
||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
||||
|
||||
[Required]
|
||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
||||
|
||||
[Required]
|
||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
||||
|
||||
[Required]
|
||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||
|
||||
[Required]
|
||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
||||
|
||||
[Required]
|
||||
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
||||
|
||||
[Required]
|
||||
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
|
||||
|
||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
|
||||
|
||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
|
||||
|
||||
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
|
||||
|
||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
||||
|
||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
|
||||
|
||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
||||
|
||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
|
||||
|
||||
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
|
||||
|
||||
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
|
||||
|
||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName);
|
||||
|
||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
|
||||
|
||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
|
||||
|
||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
|
||||
|
||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
|
||||
|
||||
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
|
||||
|
||||
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
|
||||
|
||||
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
|
||||
|
||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
||||
|
||||
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
|
||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
||||
}
|
||||
|
||||
public class TrackPicturesSizes
|
||||
{
|
||||
[Range(1, 1024)]
|
||||
public int SmallPictureWidth { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int SmallPictureHeight { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumPictureWidth { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumPictureHeight { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargePictureWidth { get; set; } = 512;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargePictureHeight { get; set; } = 512;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public class UploadIntentConfiguration
|
||||
{
|
||||
public const string SectionName = "UploadIntent";
|
||||
|
||||
[Range(10, 3600)]
|
||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
|
||||
|
||||
[Range(30, 86400)]
|
||||
public int ExpirationJobIntervalSeconds { get; set; } = 300;
|
||||
|
||||
[Range(0, 365)]
|
||||
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
||||
|
||||
[Required, MinLength(1)]
|
||||
public string TemporalRootPrefix { get; set; } = "temporal";
|
||||
|
||||
[Range(1, 30)]
|
||||
public int TemporalUploadsRetentionDays { get; set; } = 3;
|
||||
|
||||
[Range(60, 86400)]
|
||||
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IAudioTranscoderService
|
||||
{
|
||||
Task<int> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IDatabase
|
||||
{
|
||||
DbSet<User> Users { get; }
|
||||
|
||||
DbSet<PlayList> PlayLists { get; }
|
||||
|
||||
DbSet<Track> Tracks { get; }
|
||||
|
||||
DbSet<UserHasTrack> UserHasTracks { get; }
|
||||
|
||||
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||
|
||||
DbSet<Upload> Uploads { get; }
|
||||
|
||||
DbSet<UploadIntent> UploadIntents { get; }
|
||||
|
||||
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IDatabaseTransaction : IAsyncDisposable
|
||||
{
|
||||
Task CommitAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IEventBus
|
||||
{
|
||||
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IPictureService
|
||||
{
|
||||
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IStorageService
|
||||
{
|
||||
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
|
||||
|
||||
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true);
|
||||
|
||||
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
|
||||
|
||||
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public interface IStreamTicketService
|
||||
{
|
||||
StreamTicket IssueTicket(long userId, string keyPrefix);
|
||||
}
|
||||
|
||||
public record StreamTicket(string Token, int ExpiresInSeconds);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public record ObjectMetaData(string ContentType, long ContentLength);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public record StorageObject(string Key, DateTime LastModifiedUtc);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record CreatePlayListResourcesEvent(
|
||||
Guid PlayListId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record CreateTrackResourcesEvent(
|
||||
Guid TrackId,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
string AudioSourceKey,
|
||||
string AudioDestinationKey,
|
||||
string AudioProcessedFolderKey,
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record DeletePlayListEvent(
|
||||
Guid PlayListId,
|
||||
long UserId);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record DeleteTrackEvent(
|
||||
Guid TrackId,
|
||||
long UserId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdatePlayListPictureEvent(
|
||||
Guid PlayListId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdatePlayListPictureSourceEvent(
|
||||
Guid PlayListId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdateTrackAudioEvent(
|
||||
Guid TrackId,
|
||||
string SourceBucket,
|
||||
string SourceKey,
|
||||
string DestinationBucket,
|
||||
string DestinationFolderKey);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdateTrackPictureEvent(
|
||||
Guid TrackId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Ardalis.Result;
|
||||
|
||||
namespace Musify.Application.Extensions;
|
||||
|
||||
public static class ResultExtensions
|
||||
{
|
||||
public static Result<TOut> As<TIn, TOut>(this Result<TIn> result)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
ResultStatus.Ok or
|
||||
ResultStatus.Created or
|
||||
ResultStatus.NoContent => throw new InvalidOperationException($"Cannot convert a successful result ( {result.Status} ) to another type. Use Map or Bind instead."),
|
||||
ResultStatus.NotFound => Result<TOut>.NotFound(result.Errors.ToArray()),
|
||||
ResultStatus.Forbidden => Result<TOut>.Forbidden(result.Errors.ToArray()),
|
||||
ResultStatus.Unauthorized => Result<TOut>.Unauthorized(result.Errors.ToArray()),
|
||||
ResultStatus.Invalid => Result<TOut>.Invalid(result.ValidationErrors.ToArray()),
|
||||
ResultStatus.Conflict => Result<TOut>.Conflict(result.Errors.ToArray()),
|
||||
ResultStatus.CriticalError => Result<TOut>.CriticalError(result.Errors.ToArray()),
|
||||
ResultStatus.Unavailable => Result<TOut>.Unavailable(result.Errors.ToArray()),
|
||||
ResultStatus.Error => Result<TOut>.Error(string.Join(";", result.Errors)),
|
||||
_ => Result<TOut>.Error(string.Join(";", result.Errors))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
|
||||
@@ -0,0 +1,26 @@
|
||||
using X.PagedList;
|
||||
|
||||
namespace Musify.Application.Pagination
|
||||
{
|
||||
public record PaginatedResponse<T>(
|
||||
IReadOnlyCollection<T> Items,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int TotalItemCount,
|
||||
bool HasNextPage,
|
||||
bool HasPreviousPage)
|
||||
{
|
||||
public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
|
||||
{
|
||||
return new PaginatedResponse<T>(
|
||||
pagedList.ToArray(),
|
||||
pagedList.PageNumber,
|
||||
pagedList.PageSize,
|
||||
pagedList.PageCount,
|
||||
pagedList.TotalItemCount,
|
||||
pagedList.HasNextPage,
|
||||
pagedList.HasPreviousPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
||||
: ICommand<Result>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
public record CreatePlayListCommand(
|
||||
long UserId,
|
||||
string Name,
|
||||
string Description,
|
||||
Guid? PictureIntentId)
|
||||
: ICommand<Result<PlayListApplicationResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
public record DeletePlayListCommand(long UserId, Guid PlayListId)
|
||||
: ICommand<Result>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
||||
: ICommand<Result>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
public record RequestPlayListPictureUploadCommand(
|
||||
long UserId,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null)
|
||||
: ICommand<Result<PlayListPictureUploadResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Commands
|
||||
{
|
||||
public record UpdatePlayListCommand(
|
||||
long UserId,
|
||||
Guid PlayListId,
|
||||
string? NewName,
|
||||
string? NewDescription,
|
||||
Guid? NewPictureIntentId)
|
||||
: ICommand<Result<PlayListApplicationResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class AddTrackToPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTrackToPlayListCommandHandler> logger)
|
||||
: ICommandHandler<AddTrackToPlayListCommand, Result>
|
||||
{
|
||||
public async ValueTask<Result> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists
|
||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||
if (playList is null)
|
||||
{
|
||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||
return Result.NotFound();
|
||||
}
|
||||
|
||||
if (playList.UserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||
return Result.Unauthorized();
|
||||
}
|
||||
|
||||
var trackExists = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (!trackExists)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||
return Result.NotFound($"Track {request.TrackId} not found");
|
||||
}
|
||||
|
||||
var alreadyAdded = await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
||||
if (alreadyAdded)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||
return Result.Conflict("Track is already in the playlist.");
|
||||
}
|
||||
|
||||
var nextPosition = await database.PlayListHasTracks
|
||||
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||
.Select(plt => (int?)plt.Position)
|
||||
.MaxAsync(cancellationToken) + 1 ?? 0;
|
||||
|
||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||
{
|
||||
PlayListId = request.PlayListId,
|
||||
TrackId = request.TrackId,
|
||||
Position = nextPosition
|
||||
}, cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
|
||||
request.TrackId, request.PlayListId, nextPosition);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Extensions;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class CreatePlayListCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<CreatePlayListCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
PlayListConfiguration playListConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<CreatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<Result<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
{
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
UploadIntent? pictureIntent = null;
|
||||
string originalPictureName;
|
||||
|
||||
if (request.PictureIntentId.HasValue)
|
||||
{
|
||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.PictureIntentId.Value, request.UserId, cancellationToken);
|
||||
if (!validation.IsSuccess)
|
||||
return validation.As<UploadIntent, PlayListApplicationResponse>();
|
||||
|
||||
pictureIntent = validation.Value;
|
||||
originalPictureName = pictureIntent.ObjectName;
|
||||
}
|
||||
else
|
||||
{
|
||||
originalPictureName = playListConfiguration.Routes.PresetOriginalPicture;
|
||||
}
|
||||
|
||||
var playList = new PlayList
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Name = request.Name,
|
||||
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
||||
Description = request.Description,
|
||||
OriginalPictureName = originalPictureName,
|
||||
SmallPictureName = playListConfiguration.Routes.PresetSmallPicture,
|
||||
MediumPictureName = playListConfiguration.Routes.PresetMediumPicture,
|
||||
LargePictureName = playListConfiguration.Routes.PresetLargePicture
|
||||
};
|
||||
|
||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||
|
||||
if (pictureIntent is not null)
|
||||
{
|
||||
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName);
|
||||
var publishResult = await PublishCreatePlayListEventAsync(
|
||||
playList.Id, pictureIntent, finalPictureKey, cancellationToken);
|
||||
if (!publishResult.IsSuccess)
|
||||
return publishResult;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
|
||||
return Result.Error($"Failed to create playlist for user {request.UserId}");
|
||||
}
|
||||
|
||||
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
||||
return Result.Created(PlayListApplicationResponse.FromEntity(playList));
|
||||
}
|
||||
|
||||
async Task<Result<PlayListApplicationResponse>> PublishCreatePlayListEventAsync(
|
||||
Guid playListId,
|
||||
UploadIntent pictureIntent,
|
||||
string finalPictureKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new CreatePlayListResourcesEvent(
|
||||
playListId,
|
||||
pictureIntent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
new ImageSize(
|
||||
playListConfiguration.Routes.SmallPicturesPath,
|
||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
||||
playListConfiguration.PicturesSizes.SmallPictureHeight),
|
||||
new ImageSize(
|
||||
playListConfiguration.Routes.MediumPicturesPath,
|
||||
playListConfiguration.PicturesSizes.MediumPictureWidth,
|
||||
playListConfiguration.PicturesSizes.MediumPictureHeight),
|
||||
new ImageSize(
|
||||
playListConfiguration.Routes.LargePicturesPath,
|
||||
playListConfiguration.PicturesSizes.LargePictureWidth,
|
||||
playListConfiguration.PicturesSizes.LargePictureHeight)),
|
||||
cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
|
||||
return Result.Error($"Failed to publish create playlist event for playlist {playListId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class DeletePlayListCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeletePlayListCommandHandler> logger)
|
||||
: ICommandHandler<DeletePlayListCommand, Result>
|
||||
{
|
||||
public async ValueTask<Result> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
|
||||
if (playList is null)
|
||||
{
|
||||
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
|
||||
return Result.NotFound();
|
||||
}
|
||||
|
||||
if (playList.UserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
|
||||
return Result.Unauthorized();
|
||||
}
|
||||
|
||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new DeletePlayListEvent(playList.Id, request.UserId),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
|
||||
return Result.Error($"Failed to delete playlist {request.PlayListId}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
|
||||
return Result.Error($"Failed to delete playlist {request.PlayListId}");
|
||||
}
|
||||
|
||||
return Result.NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Ardalis.Result;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.PlayLists.Queries;
|
||||
using Mediator;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class GetPlayListByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListByIdQuery, Result<PlayListApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<Result<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == request.Id)
|
||||
.Select(p => PlayListApplicationResponse.FromEntity(p))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return playList is null
|
||||
? Result<PlayListApplicationResponse>.NotFound()
|
||||
: Result.Success(playList);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.PlayLists.Queries;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList.EF;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListTracksQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
|
||||
{
|
||||
public async ValueTask<Result<PaginatedResponse<TrackApplicationResponse> >> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playListExists = await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
|
||||
if (!playListExists)
|
||||
return Result<PaginatedResponse<TrackApplicationResponse>>.NotFound();
|
||||
|
||||
var tracksQuery = database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(plt => plt.Track)
|
||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pagedTracks = await tracksQuery
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Select(plt => TrackApplicationResponse.FromEntity(plt.Track))
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return Result.Success(PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Ardalis.Result;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.PlayLists.Queries;
|
||||
using X.PagedList.EF;
|
||||
using Mediator;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListsByUserIdQuery, Result<PaginatedResponse<PlayListApplicationResponse> > >
|
||||
{
|
||||
public async ValueTask<Result<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playListsQuery = database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Where(p => p.UserId == request.UserId);
|
||||
|
||||
if (request.Name is not null)
|
||||
{
|
||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
||||
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
|
||||
}
|
||||
|
||||
var totalCount = await playListsQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pagedPlayLists = await playListsQuery
|
||||
.OrderBy(p => p.CreatedAt)
|
||||
.Select(p => PlayListApplicationResponse.FromEntity(p))
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return Result.Success(PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Ardalis.Result;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.PlayLists.Queries;
|
||||
using X.PagedList.EF;
|
||||
using Mediator;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class GetPlayListsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListsQuery, Result<PaginatedResponse<PlayListApplicationResponse> >>
|
||||
{
|
||||
public async ValueTask<Result<PaginatedResponse<PlayListApplicationResponse> >> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
|
||||
|
||||
var pagedPlayLists = await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.OrderBy(p => p.Id)
|
||||
.Select(p => PlayListApplicationResponse.FromEntity(p))
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return Result.Success(PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class RemoveTrackFromPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
|
||||
: ICommandHandler<RemoveTrackFromPlayListCommand, Result>
|
||||
{
|
||||
public async ValueTask<Result> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists
|
||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||
if (playList is null)
|
||||
{
|
||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||
return Result.NotFound();
|
||||
}
|
||||
|
||||
if (playList.UserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||
return Result.Unauthorized();
|
||||
}
|
||||
|
||||
var link = await database.PlayListHasTracks
|
||||
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
||||
if (link is null)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||
return Result.NotFound("Track is not in the playlist.");
|
||||
}
|
||||
|
||||
database.PlayListHasTracks.Remove(link);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class RequestPlayListPictureUploadCommandHandler(
|
||||
IDatabase database,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
IStorageService storageService,
|
||||
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
PlayListConfiguration playListConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestPlayListPictureUploadCommand, Result<PlayListPictureUploadResponse>>
|
||||
{
|
||||
public async ValueTask<Result<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
{
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||
|
||||
var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }";
|
||||
|
||||
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
|
||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
||||
|
||||
try
|
||||
{
|
||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket,
|
||||
tempKey,
|
||||
request.ContentType,
|
||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
||||
cancellationToken);
|
||||
|
||||
await using var transaction = await database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
||||
if (!quotaCheck.IsSuccess)
|
||||
{
|
||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
||||
return quotaCheck;
|
||||
}
|
||||
|
||||
var intent = new UploadIntent
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = tempKey,
|
||||
ObjectName = objectName,
|
||||
ContentType = request.ContentType,
|
||||
ExpectedSizeBytes = effectiveSizeBytes,
|
||||
Purpose = UploadIntentPurpose.PlayListPicture,
|
||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
||||
};
|
||||
|
||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
|
||||
|
||||
return Result.Success(new PlayListPictureUploadResponse(
|
||||
intent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
tempKey,
|
||||
objectName,
|
||||
request.ContentType,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||
uploadUrl));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
|
||||
return Result.Error("Failed to generate upload URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.PlayLists.Commands;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Extensions;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.PlayLists.Handlers
|
||||
{
|
||||
public class UpdatePlayListCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<UpdatePlayListCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
PlayListConfiguration playListConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<Result<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||
if (playListEntity is null)
|
||||
{
|
||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||
return Result.NotFound();
|
||||
}
|
||||
|
||||
if (playListEntity.UserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||
return Result.Unauthorized();
|
||||
}
|
||||
|
||||
if (request.NewName is not null)
|
||||
{
|
||||
playListEntity.Name = request.NewName;
|
||||
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
|
||||
}
|
||||
|
||||
if (request.NewDescription is not null)
|
||||
{
|
||||
playListEntity.Description = request.NewDescription;
|
||||
}
|
||||
|
||||
UploadIntent? pictureIntent = null;
|
||||
string? finalPictureKey = null;
|
||||
|
||||
if (request.NewPictureIntentId.HasValue)
|
||||
{
|
||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
||||
if (!validation.IsSuccess)
|
||||
return validation.As<UploadIntent, PlayListApplicationResponse>();
|
||||
|
||||
pictureIntent = validation.Value;
|
||||
playListEntity.OriginalPictureName = pictureIntent.ObjectName;
|
||||
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||
}
|
||||
|
||||
database.PlayLists.Update(playListEntity);
|
||||
|
||||
if (pictureIntent is not null && finalPictureKey is not null)
|
||||
{
|
||||
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
|
||||
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
|
||||
if (!publishResult.IsSuccess)
|
||||
return publishResult;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
|
||||
return Result.Error($"Failed to update playlist {playListEntity.Id}");
|
||||
}
|
||||
|
||||
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
||||
return Result.Success(PlayListApplicationResponse.FromEntity(playListEntity));
|
||||
}
|
||||
|
||||
async Task<Result<PlayListApplicationResponse>> PublishUpdatePlayListPictureSourceEventAsync(
|
||||
Guid playListId,
|
||||
UploadIntent pictureIntent,
|
||||
string finalPictureKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new UpdatePlayListPictureSourceEvent(
|
||||
playListId,
|
||||
pictureIntent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
new ImageSize(
|
||||
playListConfiguration.Routes.SmallPicturesPath,
|
||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
||||
playListConfiguration.PicturesSizes.SmallPictureHeight),
|
||||
new ImageSize(
|
||||
playListConfiguration.Routes.MediumPicturesPath,
|
||||
playListConfiguration.PicturesSizes.MediumPictureWidth,
|
||||
playListConfiguration.PicturesSizes.MediumPictureHeight),
|
||||
new ImageSize(
|
||||
playListConfiguration.Routes.LargePicturesPath,
|
||||
playListConfiguration.PicturesSizes.LargePictureWidth,
|
||||
playListConfiguration.PicturesSizes.LargePictureHeight)),
|
||||
cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
|
||||
return Result.Error($"Failed to publish update playlist picture source event for playlist {playListId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Queries
|
||||
{
|
||||
public record GetPlayListByIdQuery(Guid Id)
|
||||
: IQuery<Result<PlayListApplicationResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Queries
|
||||
{
|
||||
public record GetPlayListTracksQuery(
|
||||
Guid PlayListId,
|
||||
int PageNumber = 1,
|
||||
int PageSize = 10)
|
||||
: IQuery<Result<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Queries
|
||||
{
|
||||
public record GetPlayListsByUserIdQuery(
|
||||
long UserId,
|
||||
string? Name,
|
||||
int PageNumber = 1,
|
||||
int PageSize = 10)
|
||||
: IQuery<Result<PaginatedResponse<PlayListApplicationResponse>> >;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
|
||||
namespace Musify.Application.PlayLists.Queries
|
||||
{
|
||||
public record GetPlayListsQuery(int PageNumber = 1, int PageSize = 10)
|
||||
: IQuery<Result<PaginatedResponse<PlayListApplicationResponse>> >;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.PlayLists.Responses
|
||||
{
|
||||
public record PlayListApplicationResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string SmallImageKeyName,
|
||||
string MediumImageKeyName,
|
||||
string LargeImageKeyName,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt)
|
||||
{
|
||||
public static PlayListApplicationResponse FromEntity(PlayList playList)
|
||||
{
|
||||
return new PlayListApplicationResponse(
|
||||
playList.Id,
|
||||
playList.Name,
|
||||
playList.Description,
|
||||
playList.SmallPictureName,
|
||||
playList.MediumPictureName,
|
||||
playList.LargePictureName,
|
||||
playList.CreatedAt,
|
||||
playList.UpdatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Musify.Application.PlayLists.Responses
|
||||
{
|
||||
public record PlayListPictureUploadResponse(
|
||||
Guid IntentId,
|
||||
string Bucket,
|
||||
string Key,
|
||||
string PictureName,
|
||||
string ContentType,
|
||||
int ExpiresInSeconds,
|
||||
string UploadUrl);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Ardalis.Result;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Services;
|
||||
|
||||
public sealed class UploadIntentValidator(
|
||||
IDatabase database,
|
||||
IStorageService storageService)
|
||||
{
|
||||
public async Task<Result> CheckQuotaAsync(
|
||||
UploadIntentConfiguration config,
|
||||
long userId,
|
||||
long requiredBytes,
|
||||
int requiredIntentCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activeIntents = await database.UploadIntents
|
||||
.AsNoTracking()
|
||||
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
||||
return Result.Invalid(new ValidationError("Upload intent limit exceeded. Wait for existing uploads to complete or expire."));
|
||||
|
||||
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
|
||||
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
||||
return Result.Invalid(new ValidationError("Upload byte quota exceeded. Wait for existing uploads to complete or expire."));
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public async Task<Result<UploadIntent>> ValidateAndLoadAsync(
|
||||
UploadIntentConfiguration config,
|
||||
Guid intentId,
|
||||
long userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var intent = await database.UploadIntents
|
||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
||||
|
||||
if (intent is null || intent.UserId != userId)
|
||||
return Result.NotFound("Upload intent not found or not accessible.");
|
||||
|
||||
if (intent.Status == UploadIntentStatus.Consumed)
|
||||
return Result.Conflict("Upload intent has already been consumed.");
|
||||
|
||||
if (intent.Status == UploadIntentStatus.Expired || intent.ExpiresAt < DateTime.UtcNow)
|
||||
return Result.Invalid(new ValidationError("Upload intent has expired."));
|
||||
|
||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
||||
if (metadata is null)
|
||||
return Result.NotFound("Uploaded object not found in storage. Upload the file first.");
|
||||
|
||||
if (metadata.ContentLength > config.MaxUploadBytes)
|
||||
return Result.Invalid(new ValidationError($"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."));
|
||||
|
||||
return Result.Success(intent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
public record ImageSize(
|
||||
string SavePath,
|
||||
int Width,
|
||||
int Height);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
public static class StorageKey
|
||||
{
|
||||
public static string Combine(params string[] segments)
|
||||
{
|
||||
return string.Join('/', segments
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s.Trim().Trim('/', '\\')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Commands
|
||||
{
|
||||
public record CreateTrackCommand(
|
||||
long UserId,
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId)
|
||||
: ICommand<Result<TrackApplicationResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
|
||||
namespace Musify.Application.Tracks.Commands
|
||||
{
|
||||
public record DeleteTrackCommand(
|
||||
long UserId,
|
||||
Guid TrackId)
|
||||
: ICommand<Result>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Commands
|
||||
{
|
||||
public record RequestTrackUploadUrlsCommand(
|
||||
long UserId,
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType,
|
||||
long? ExpectedPictureSizeBytes = null,
|
||||
long? ExpectedAudioSizeBytes = null)
|
||||
: ICommand<Result<TrackUploadUrlsResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Tracks.Commands;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Application.Extensions;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks.Handlers
|
||||
{
|
||||
public class CreateTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<CreateTrackCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<Result<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await database.Users.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
{
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.PictureIntentId, request.UserId, cancellationToken);
|
||||
if (!pictureValidation.IsSuccess)
|
||||
return pictureValidation.As<UploadIntent, TrackApplicationResponse>();
|
||||
|
||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.AudioIntentId, request.UserId, cancellationToken);
|
||||
if (!audioValidation.IsSuccess)
|
||||
return audioValidation.As<UploadIntent, TrackApplicationResponse>();
|
||||
|
||||
var pictureIntent = pictureValidation.Value;
|
||||
var audioIntent = audioValidation.Value;
|
||||
|
||||
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||
|
||||
var trackEntity = new Track
|
||||
{
|
||||
Title = request.Title,
|
||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||
OriginalPictureName = pictureIntent.ObjectName,
|
||||
OriginalAudioName = audioIntent.ObjectName,
|
||||
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
||||
PicturesProcessingStatus = ProcessingStatus.Pending,
|
||||
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
|
||||
};
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
||||
{
|
||||
UserId = request.UserId,
|
||||
TrackId = trackEntity.Id
|
||||
}, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new CreateTrackResourcesEvent(
|
||||
trackEntity.Id,
|
||||
pictureIntent.Id,
|
||||
audioIntent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
audioIntent.Key,
|
||||
finalAudioKey,
|
||||
audioProcessedFolderKey,
|
||||
new ImageSize(
|
||||
trackConfiguration.Routes.SmallPicturesPath,
|
||||
trackConfiguration.PicturesSizes.SmallPictureWidth,
|
||||
trackConfiguration.PicturesSizes.SmallPictureHeight),
|
||||
new ImageSize(
|
||||
trackConfiguration.Routes.MediumPicturesPath,
|
||||
trackConfiguration.PicturesSizes.MediumPictureWidth,
|
||||
trackConfiguration.PicturesSizes.MediumPictureHeight),
|
||||
new ImageSize(
|
||||
trackConfiguration.Routes.LargePicturesPath,
|
||||
trackConfiguration.PicturesSizes.LargePictureWidth,
|
||||
trackConfiguration.PicturesSizes.LargePictureHeight)),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id);
|
||||
return Result.Error($"Failed to create track for {request.Title}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to save track {Title}", request.Title);
|
||||
return Result.Error($"Failed to create track for {request.Title}");
|
||||
}
|
||||
|
||||
return Result.Created(TrackApplicationResponse.FromEntity(trackEntity));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Tracks.Commands;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks.Handlers
|
||||
{
|
||||
public class DeleteTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeleteTrackCommandHandler> logger)
|
||||
: ICommandHandler<DeleteTrackCommand, Result>
|
||||
{
|
||||
public async ValueTask<Result> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||
if (track is null)
|
||||
{
|
||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||
return Result.NotFound();
|
||||
}
|
||||
|
||||
var isOwner = await database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(ut => ut.TrackId == request.TrackId && ut.UserId == request.UserId, cancellationToken);
|
||||
if (!isOwner)
|
||||
{
|
||||
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
||||
return Result.Unauthorized();
|
||||
}
|
||||
|
||||
if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Processing
|
||||
|| track.AudioTranscodeProcessingStatus == ProcessingStatus.Pending)
|
||||
{
|
||||
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
|
||||
return Result.Conflict("Track is currently being processed and cannot be deleted");
|
||||
}
|
||||
|
||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new DeleteTrackEvent(track.Id, request.UserId),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
|
||||
return Result.Error($"Failed to delete track {request.TrackId}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId);
|
||||
return Result.Error($"Failed to delete track {request.TrackId}");
|
||||
}
|
||||
|
||||
return Result.NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Queries;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Handlers
|
||||
{
|
||||
public class GetTrackByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTrackByIdQuery, Result<TrackApplicationResponse> >
|
||||
{
|
||||
public async ValueTask<Result<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return track is null
|
||||
? Result<TrackApplicationResponse>.NotFound()
|
||||
: Result.Success(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Queries;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Handlers
|
||||
{
|
||||
public class GetTrackStreamQueryHandler(
|
||||
IDatabase database,
|
||||
IStreamTicketService ticketService,
|
||||
TrackConfiguration trackConfiguration,
|
||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
||||
ILogger<GetTrackStreamQueryHandler> logger)
|
||||
: IQueryHandler<GetTrackStreamQuery, Result<TrackStreamResponse>>
|
||||
{
|
||||
public async ValueTask<Result<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (track is null)
|
||||
return Result<TrackStreamResponse>.NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(track.AudioFolderName)
|
||||
|| track.AudioTranscodeProcessingStatus != ProcessingStatus.Completed)
|
||||
{
|
||||
logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
|
||||
return Result<TrackStreamResponse>.Conflict("Track audio is not available for streaming yet.");
|
||||
}
|
||||
|
||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName);
|
||||
var keyPrefix = $"{folderPath}/";
|
||||
|
||||
var ticket = ticketService.IssueTicket(request.UserId, keyPrefix);
|
||||
|
||||
var manifestUrl = string.Join('/',
|
||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
||||
"media",
|
||||
folderPath,
|
||||
streamGatewayConfiguration.AudioFileName);
|
||||
|
||||
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId);
|
||||
|
||||
return Result.Success(new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.Tracks.Queries;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks.Handlers
|
||||
{
|
||||
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksByUserIdQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
|
||||
{
|
||||
public async ValueTask<Result<PaginatedResponse<TrackApplicationResponse> >> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
|
||||
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
|
||||
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Include(ut => ut.Track)
|
||||
.Where(p => p.UserId == request.UserId);
|
||||
|
||||
if (request.Name is not null)
|
||||
{
|
||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pagedTracks = await tracksQuery
|
||||
.OrderBy(t => t.Track.CreatedAt)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track))
|
||||
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
|
||||
|
||||
return Result.Success(PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.Tracks.Queries;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks.Handlers
|
||||
{
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
|
||||
{
|
||||
public async ValueTask<Result<PaginatedResponse<TrackApplicationResponse> >> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.OrderBy(t => t.Id)
|
||||
.Include(ut => ut.Track)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track));
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
var pagedTracks = await tracksQuery.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return Result.Success(PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tracks.Commands;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks.Handlers
|
||||
{
|
||||
public class RequestTrackUploadUrlsCommandHandler(
|
||||
IDatabase database,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
IStorageService storageService,
|
||||
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
|
||||
{
|
||||
public async ValueTask<Result<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
{
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Result.NotFound($"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var effectivePictureSize = request.ExpectedPictureSizeBytes
|
||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||
var effectiveAudioSize = request.ExpectedAudioSizeBytes
|
||||
?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes;
|
||||
|
||||
var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}";
|
||||
var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}";
|
||||
|
||||
var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath(
|
||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, pictureObjectName);
|
||||
var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath(
|
||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, audioObjectName);
|
||||
|
||||
try
|
||||
{
|
||||
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
|
||||
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket,
|
||||
tempPictureKey,
|
||||
request.PictureContentType,
|
||||
expiresIn,
|
||||
cancellationToken);
|
||||
|
||||
var audioUploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket,
|
||||
tempAudioKey,
|
||||
request.AudioContentType,
|
||||
expiresIn,
|
||||
cancellationToken);
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
|
||||
await using var transaction = await database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
||||
uploadIntentConfiguration,
|
||||
request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken);
|
||||
if (!quotaCheck.IsSuccess)
|
||||
{
|
||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
||||
return quotaCheck;
|
||||
}
|
||||
|
||||
var pictureIntent = new UploadIntent
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = tempPictureKey,
|
||||
ObjectName = pictureObjectName,
|
||||
ContentType = request.PictureContentType,
|
||||
ExpectedSizeBytes = effectivePictureSize,
|
||||
Purpose = UploadIntentPurpose.TrackPicture,
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
|
||||
var audioIntent = new UploadIntent
|
||||
{
|
||||
UserId = request.UserId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = tempAudioKey,
|
||||
ObjectName = audioObjectName,
|
||||
ContentType = request.AudioContentType,
|
||||
ExpectedSizeBytes = effectiveAudioSize,
|
||||
Purpose = UploadIntentPurpose.TrackAudio,
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
|
||||
await database.UploadIntents.AddAsync(pictureIntent, cancellationToken);
|
||||
await database.UploadIntents.AddAsync(audioIntent, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)",
|
||||
pictureIntent.Id, audioIntent.Id, request.UserId);
|
||||
|
||||
return Result.Success(new TrackUploadUrlsResponse(
|
||||
pictureIntent.Id,
|
||||
audioIntent.Id,
|
||||
storageConfiguration.Bucket,
|
||||
tempPictureKey,
|
||||
pictureObjectName,
|
||||
request.PictureContentType,
|
||||
pictureUploadUrl,
|
||||
tempAudioKey,
|
||||
audioObjectName,
|
||||
request.AudioContentType,
|
||||
audioUploadUrl,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId);
|
||||
return Result.Error("Failed to generate upload URLs");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Queries
|
||||
{
|
||||
public record GetTrackByIdQuery(Guid TrackId)
|
||||
: IQuery<Result<TrackApplicationResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Queries
|
||||
{
|
||||
public record GetTrackStreamQuery(Guid TrackId, long UserId)
|
||||
: IQuery<Result<TrackStreamResponse>>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Queries
|
||||
{
|
||||
public record GetTracksByUserIdQuery(
|
||||
long UserId,
|
||||
string? Name = null,
|
||||
int PageNumber = 1,
|
||||
int PageSize = 10)
|
||||
: IQuery<Result<PaginatedResponse<TrackApplicationResponse> > >;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks.Queries
|
||||
{
|
||||
public record GetTracksQuery(int PageNumber = 1, int PageSize = 10)
|
||||
: IQuery<Result<PaginatedResponse<TrackApplicationResponse> >>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record TrackApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt)
|
||||
{
|
||||
public static TrackApplicationResponse FromEntity(Track track)
|
||||
{
|
||||
return new TrackApplicationResponse(
|
||||
track.Id,
|
||||
track.Title,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record TrackStreamResponse(
|
||||
string ManifestUrl,
|
||||
string Ticket,
|
||||
int ExpiresInSeconds);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record TrackUploadUrlsResponse(
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureKey,
|
||||
string PictureName,
|
||||
string PictureContentType,
|
||||
string PictureUploadUrl,
|
||||
string AudioKey,
|
||||
string AudioName,
|
||||
string AudioContentType,
|
||||
string AudioUploadUrl,
|
||||
int ExpiresInSeconds);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Users.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Users.Commands
|
||||
{
|
||||
public record CreateUserCommand(long Id,
|
||||
string Name,
|
||||
string? FirstName,
|
||||
string? SecondName)
|
||||
: ICommand<Result<UserApplicationResponse>>
|
||||
{
|
||||
public static User ToEntity(CreateUserCommand command)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = command.Id,
|
||||
Name = command.Name,
|
||||
NormalizedName = command.Name.ToUpperInvariant(),
|
||||
FirstName = command.FirstName,
|
||||
SecondName = command.SecondName
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
|
||||
namespace Musify.Application.Users.Commands
|
||||
{
|
||||
public record SyncUserCommand(
|
||||
long Id,
|
||||
string Name,
|
||||
string? FirstName,
|
||||
string? SecondName,
|
||||
string? ProfilePictureUrl)
|
||||
: ICommand<Result>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Users.Commands;
|
||||
using Musify.Application.Users.Responses;
|
||||
|
||||
namespace Musify.Application.Users.Handlers
|
||||
{
|
||||
public class CreateUserCommandHandler(IDatabase database, ILogger<CreateUserCommandHandler> logger)
|
||||
: ICommandHandler<CreateUserCommand, Result<UserApplicationResponse> >
|
||||
{
|
||||
public async ValueTask<Result<UserApplicationResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken);
|
||||
if (userExists)
|
||||
{
|
||||
logger.LogInformation("User {UserId} already exists", request.Id);
|
||||
return Result<UserApplicationResponse>.Conflict($"User {request.Id} already exists");
|
||||
}
|
||||
|
||||
var newUser = CreateUserCommand.ToEntity(request);
|
||||
|
||||
try
|
||||
{
|
||||
await database.Users.AddAsync(newUser, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to create user {UserId}", request.Id);
|
||||
return Result.Error($"Failed to create user {request.Id}");
|
||||
}
|
||||
|
||||
logger.LogInformation("Created user {UserId}", newUser.Id);
|
||||
return Result.Created(UserApplicationResponse.FromEntity(newUser));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Users.Queries;
|
||||
using Musify.Application.Users.Responses;
|
||||
|
||||
namespace Musify.Application.Users.Handlers
|
||||
{
|
||||
public class GetUserByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetUserByIdQuery, Result<UserApplicationResponse> >
|
||||
{
|
||||
public async ValueTask<Result<UserApplicationResponse>> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await database.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Id == request.Id)
|
||||
.Select(u => UserApplicationResponse.FromEntity(u))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return user is null
|
||||
? Result<UserApplicationResponse>.NotFound()
|
||||
: Result.Success(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.Users.Queries;
|
||||
using Musify.Application.Users.Responses;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Users.Handlers
|
||||
{
|
||||
public class GetUsersQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetUsersQuery, Result<PaginatedResponse<UserApplicationResponse> > >
|
||||
{
|
||||
public async ValueTask<Result<PaginatedResponse<UserApplicationResponse> >> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = database.Users
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (request.UsernameSearch is not null)
|
||||
{
|
||||
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
|
||||
user = user.Where(u => u.NormalizedName.Contains(normalizedUsername));
|
||||
}
|
||||
|
||||
var totalCount = await user.CountAsync(cancellationToken);
|
||||
var pagedUser = await user
|
||||
.Select(u => UserApplicationResponse.FromEntity(u))
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return Result.Success(PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUser));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Users.Commands;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Users.Handlers
|
||||
{
|
||||
public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandHandler> logger)
|
||||
: ICommandHandler<SyncUserCommand, Result>
|
||||
{
|
||||
public async ValueTask<Result> Handle(SyncUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await database.Users
|
||||
.SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await database.Users.AddAsync(new User
|
||||
{
|
||||
Id = request.Id,
|
||||
Name = request.Name,
|
||||
NormalizedName = request.Name.ToUpperInvariant(),
|
||||
FirstName = request.FirstName,
|
||||
SecondName = request.SecondName,
|
||||
ProfilePictureUrl = request.ProfilePictureUrl
|
||||
}, cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Provisioned user {UserId}", request.Id);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
user.Name = request.Name;
|
||||
user.NormalizedName = request.Name.ToUpperInvariant();
|
||||
user.FirstName = request.FirstName;
|
||||
user.SecondName = request.SecondName;
|
||||
user.ProfilePictureUrl = request.ProfilePictureUrl;
|
||||
|
||||
var written = await database.SaveChangesAsync(cancellationToken);
|
||||
if (written > 0)
|
||||
logger.LogInformation("Synced profile for user {UserId}", request.Id);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Users.Responses;
|
||||
|
||||
namespace Musify.Application.Users.Queries
|
||||
{
|
||||
public record GetUserByIdQuery(long Id)
|
||||
: IQuery<Result<UserApplicationResponse> >;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Ardalis.Result;
|
||||
using Mediator;
|
||||
using Musify.Application.Pagination;
|
||||
using Musify.Application.Users.Responses;
|
||||
|
||||
namespace Musify.Application.Users.Queries
|
||||
{
|
||||
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch = null)
|
||||
: IQuery<Result<PaginatedResponse<UserApplicationResponse> >>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Users.Responses
|
||||
{
|
||||
public record UserApplicationResponse(
|
||||
long Id,
|
||||
string Name,
|
||||
string? FirstName,
|
||||
string? SecondName,
|
||||
string? ProfilePictureUrl,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt)
|
||||
{
|
||||
public static UserApplicationResponse FromEntity(User user)
|
||||
{
|
||||
return new UserApplicationResponse(
|
||||
user.Id,
|
||||
user.Name,
|
||||
user.FirstName,
|
||||
user.SecondName,
|
||||
user.ProfilePictureUrl,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user