refactor: standardize project names to Musify.* and flatten layout

- Flatten nested per-repo folders into flat Musify.* projects at root
- Rename assemblies/csproj: Domain->Musify.Domain, Application->Musify.Application,
  Infrastructure->Musify.Infrastructure, WebApi->Musify.Api, Worker->Musify.Worker,
  StreamingGateway->Musify.StreamingGateway
- Rename WebApi.* namespaces to Musify.Api.*
- Update all ProjectReference paths and the solution file
- Consolidate stray per-repo dotfiles; add root .gitattributes
- Move flutter client out to _archive
This commit is contained in:
2026-07-05 03:32:48 +02:00
parent 638e446bef
commit 3be31a6378
259 changed files with 82 additions and 1711 deletions
@@ -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);
}
}
+26
View File
@@ -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,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="..\Musify.Domain\Musify.Domain.csproj" />
</ItemGroup>
</Project>
+4
View File
@@ -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();
}
}
}
@@ -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);
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Musify.Application.Shared
{
public record ImageSize(
string SavePath,
int Width,
int Height);
}
+12
View File
@@ -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);
}
}
}