chore: group .NET into backend/ and drop non-.NET test artifacts

- Move all .NET (6 projects, solution, Directory.*.props, nuget.config)
  into backend/ so the monorepo root can host non-.NET projects later
- Remove throwaway test artifacts: player.html, pl.json
- Trim dead Flutter rules from .gitignore

Build from backend/Musify.slnx: 0 warnings, 0 errors.
This commit is contained in:
2026-07-05 03:43:52 +02:00
parent c7fcf2d289
commit f2933caa90
247 changed files with 0 additions and 229 deletions
+9
View File
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+51
View File
@@ -0,0 +1,51 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Ardalis.Result" Version="10.1.0" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.23.4" />
<PackageVersion Include="DispatchR.Mediator" Version="2.2.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageVersion Include="MassTransit" Version="8.5.9" />
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.9" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.9" />
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.8" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" />
<PackageVersion Include="MimeMapping" Version="4.0.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Api.ProviderBuilderExtensions" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Process" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.14" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="X.PagedList" Version="10.5.9" />
<PackageVersion Include="X.PagedList.EF" Version="10.5.9" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,55 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Authentication": {
"MetadataAddress": "http://192.168.0.120:8080/.well-known/openid-configuration",
"IssuerAddress": "http://192.168.0.120:8080",
"AudienceAddress": "375710146774499331",
"ClientId": "375710146774499331",
"AuthorizationEndpoint": "http://192.168.0.120:8080/oauth/v2/authorize?prompt=login",
"TokenEndpoint": "http://192.168.0.120:8080/oauth/v2/token",
"Scopes": [ "openid", "profile", "email" ],
"ScalarRedirectUri": "http://192.168.0.120:5111/scalar/",
"RequireHttpsMetadata": false
},
"Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres"
},
"MassTransit": {
"Address": "rabbitmq://127.0.0.1:5672",
"Username": "guest",
"Password": "guest"
},
"InfrastructureStorage": {
"Address": "http://localhost:8333",
"AccessKey": "admin_access_key",
"SecretAccessKey": "admin_secret_key",
"ForcePathStyle": true,
"UseHttp": true
},
"ApplicationStorage": {
"Bucket": "webapi-storage"
},
"StreamGateway": {
"PublicBaseUrl": "http://localhost:8081",
"ManifestFileName": "manifest.mpd"
},
"StreamTicket": {
"PrivateKeyPath": "D:\\weed\\stream_private.pem",
"Audience": "media-gateway",
"Issuer": "musify-webapi",
"TicketTtlSeconds": 3600
},
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": "D:\\tempsFilesDev"
}
}
}
+128
View File
@@ -0,0 +1,128 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Authentication": {
"MetadataAddress": "",
"IssuerAddress": "",
"AudienceAddress": "",
"ClientId": "",
"AuthorizationEndpoint": "",
"TokenEndpoint": "",
"Scopes": [],
"ScalarRedirectUri": "",
"RequireHttpsMetadata": true
},
"Database": {
"ConnectionString": ""
},
"MassTransit": {
"Address": "",
"Username": "",
"Password": ""
},
"InfrastructureStorage": {
"Address": "",
"AccessKey": "",
"SecretAccessKey": "",
"ForcePathStyle": false,
"UseHttp": false
},
"ApplicationStorage": {
"Bucket": ""
},
"StreamGateway": {
"PublicBaseUrl": "",
"AudioFileName": "audio.m4a"
},
"StreamTicket": {
"PrivateKeyPath": "",
"Audience": "media-gateway",
"Issuer": "musify-webapi",
"TicketTtlSeconds": 3600
},
"OpenTelemetry": {
"OtlpEndpoint": ""
},
"PlayList": {
"Routes": {
"UploadsFolder": "uploads",
"ParentFolders": "PlayLists",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures",
"PresetOriginalPicture": "PresetOriginalPicture.webp",
"PresetSmallPicture": "PresetSmallPicture.webp",
"PresetMediumPicture": "PresetMediumPicture.webp",
"PresetLargePicture": "PresetLargePicture.webp"
},
"PicturesSizes": {
"SmallPictureWidth": 128,
"SmallPictureHeight": 128,
"MediumPictureWidth": 256,
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
}
},
"Track": {
"Routes": {
"UploadsFolder": "uploads",
"ParentFolder": "Tracks",
"OriginalPicturesFolder": "OriginalPictures",
"SmallPicturesFolder": "SmallPictures",
"MediumPicturesFolder": "MediumPictures",
"LargePicturesFolder": "LargePictures",
"PresetOriginalPicture": "PresetOriginalPicture.webp",
"PresetSmallPicture": "PresetSmallPicture.webp",
"PresetMediumPicture": "PresetMediumPicture.webp",
"PresetLargePicture": "PresetLargePicture.webp",
"OriginalAudiosFolder": "OriginalAudios",
"ProcessedAudioFolder": "ProcessedAudios"
},
"PicturesSizes": {
"SmallPictureWidth": 128,
"SmallPictureHeight": 128,
"MediumPictureWidth": 256,
"MediumPictureHeight": 256,
"LargePictureWidth": 512,
"LargePictureHeight": 512
}
},
"AudioTranscoder": {
"TranscodingTimeout": "00:05:00",
"Ffmpeg": {
"ExecutableName": "ffmpeg",
"OutputFileName": "audio.m4a",
"AudioCodec": "aac",
"AudioBitrate": "128k",
"AudioChannels": 2,
"AudioSampleRate": 48000,
"AudioProfile": "aac_low",
"AdditionalArguments": ""
}
},
"UploadIntent": {
"UploadUrlExpiresInSeconds": 120,
"MaxActiveUploadIntentsPerUser": 5,
"MaxActiveUploadBytesPerUser": 209715200,
"MaxUploadBytes": 104857600,
"DefaultExpectedPictureSizeBytes": 10485760,
"DefaultExpectedAudioSizeBytes": 104857600,
"ExpirationJobIntervalSeconds": 300,
"ExpiredIntentsRetentionDays": 7,
"TempRootPrefix": "temp",
"TempUploadsRetentionDays": 3,
"TempCleanupJobIntervalSeconds": 3600
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": ""
}
}
}
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Api.Authentication;
public sealed class AuthenticationConfiguration
{
public const string SectionName = "Authentication";
[Required]
[Url]
public required string MetadataAddress { get; set; }
[Required]
public required string IssuerAddress { get; set; }
[Required]
public required string AudienceAddress { get; set; }
[Required]
public required string ClientId { get; set; }
public string? ClientSecret { get; set; }
[Required]
[Url]
public required string AuthorizationEndpoint { get; set; }
[Required]
[Url]
public required string TokenEndpoint { get; set; }
[Required]
[MinLength(1)]
public required string[] Scopes { get; set; }
public string? ScalarRedirectUri { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
}
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
namespace Musify.Api.Authentication;
public static class AuthenticationExtensions
{
public static IServiceCollection AddAuthenticationConfiguration(
this IServiceCollection services,
IConfiguration configuration)
{
services
.AddOptionsWithValidateOnStart<AuthenticationConfiguration>()
.Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName))
.ValidateDataAnnotations();
services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
services.AddScoped<JwtBearerEventsHandler>();
services.ConfigureOptions<JwtBearerOptionsSetup>();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
services.AddAuthorization();
return services;
}
}
@@ -0,0 +1,43 @@
using System.Reflection;
using System.Security.Claims;
namespace Musify.Api.Authentication;
public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
{
private CurrentUser(ClaimsPrincipal principal)
{
Principal = principal;
var isAuthenticated = principal.Identity?.IsAuthenticated;
IsAuthenticated = isAuthenticated.HasValue && isAuthenticated.Value;
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (long.TryParse(rawId, out var id) && id != 0)
Id = id;
Username = principal.FindFirstValue(ClaimTypes.Name);
FirstName = principal.FindFirstValue(ClaimTypes.GivenName);
LastName = principal.FindFirstValue(ClaimTypes.Surname);
}
public ClaimsPrincipal Principal { get; }
public bool IsAuthenticated { get; }
public long? Id { get; }
public string? Username { get; }
public string? FirstName { get; }
public string? LastName { get; }
public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid.");
public bool HasClaim(string type) => Principal.HasClaim(c => c.Type == type);
public string? FindClaim(string type) => Principal.FindFirstValue(type);
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
}
@@ -0,0 +1,54 @@
using Mediator;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Musify.Application.Users.Commands;
using System.Security.Claims;
namespace Musify.Api.Authentication;
public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
{
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
{
var principal = tokenValidatedContext.Principal!;
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(rawId, out var userId))
{
logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId);
return;
}
var username = principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue("preferred_username")
?? principal.FindFirstValue(ClaimTypes.Email)
?? rawId!;
var firstName = principal.FindFirstValue(ClaimTypes.GivenName);
var lastName = principal.FindFirstValue(ClaimTypes.Surname);
var profilePictureUrl = principal.FindFirstValue("picture");
try
{
await mediator.Send(
new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl),
tokenValidatedContext.HttpContext.RequestAborted);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId);
}
}
public override Task AuthenticationFailed(AuthenticationFailedContext context)
{
logger.LogWarning(context.Exception, "JWT authentication failed");
return Task.CompletedTask;
}
public override Task Challenge(JwtBearerChallengeContext context)
{
if (context.AuthenticateFailure is not null)
logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription);
return Task.CompletedTask;
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
namespace Musify.Api.Authentication;
public sealed class JwtBearerOptionsSetup(IOptions<AuthenticationConfiguration> options)
: IConfigureNamedOptions<JwtBearerOptions>
{
private readonly AuthenticationConfiguration _configuration = options.Value;
public void Configure(string? name, JwtBearerOptions options)
{
if (name != JwtBearerDefaults.AuthenticationScheme)
{
return;
}
Configure(options);
}
public void Configure(JwtBearerOptions options)
{
options.MetadataAddress = _configuration.MetadataAddress;
options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata;
options.Audience = _configuration.AudienceAddress;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = _configuration.IssuerAddress,
ValidAudience = _configuration.AudienceAddress,
RoleClaimType = ClaimTypes.Role,
NameClaimType = ClaimTypes.Name,
};
options.EventsType = typeof(JwtBearerEventsHandler);
}
}
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record CreatePlayListRequest(
string Name,
string Description,
Guid? PictureIntentId);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record RequestPlayListPictureUploadRequest(
string FileType,
string ContentType,
long? ExpectedSizeBytes = null);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record UpdatePlayListRequest(
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId);
@@ -0,0 +1,6 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record CreateTrackRequest(
string Title,
Guid PictureIntentId,
Guid AudioIntentId);
@@ -0,0 +1,9 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record RequestTrackUploadUrlsRequest(
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null);
@@ -0,0 +1,7 @@
namespace Musify.Api.DataTransferObjects.Users;
public record CreateUserRequest(
long Id,
string Name,
string? FirstName,
string? SecondName);
@@ -0,0 +1,192 @@
using Mediator;
using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Queries;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.Extensions;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
public static class PlayListEndpoints
{
public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/playLists")
.WithTags("PlayLists");
group.MapGet("/", GetPlayLists)
.WithName("GetPlayLists")
.WithSummary("Get Paginated PlayLists.");
group.MapGet("/{id}", GetPlayListById)
.WithName("GetPlayListById")
.WithSummary("Get A PlayList By Id.");
group.MapGet("/users/{userId}", GetPlayListsByUserId)
.WithName("GetPlayListsByUserId")
.WithSummary("Get Paginated PlayLists For A User.");
group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
.WithName("GetPlayListTracks")
.WithSummary("Get Paginated Tracks Of A PlayList.");
group.MapPost("/", CreatePlayList)
.WithName("CreatePlayList")
.WithSummary("Create A New PlayList.")
.AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>()
.RequireAuthorization();
group.MapPut("/{playlistId}", UpdatePlayList)
.WithName("UpdatePlayList")
.WithSummary("Update An Existing PlayList.")
.AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>()
.RequireAuthorization();
group.MapDelete("/{playlistId}", DeletePlayList)
.WithName("DeletePlayList")
.WithSummary("Delete A PlayList.")
.RequireAuthorization();
group.MapPost("/upload-picture", RequestPlayListPictureUpload)
.WithName("RequestPlayListPictureUpload")
.WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.")
.AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>()
.RequireAuthorization();
group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList)
.WithName("AddTrackToPlayList")
.WithSummary("Add A Track To A PlayList.")
.RequireAuthorization();
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
.WithName("RemoveTrackFromPlayList")
.WithSummary("Remove A Track From A PlayList.")
.RequireAuthorization();
return app;
}
private static async Task<IResult> GetPlayLists(
IMediator mediator,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetPlayListById(
IMediator mediator,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetPlayListByIdQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetPlayListsByUserId(
IMediator mediator,
long userId,
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetPlayListTracks(
IMediator mediator,
Guid playlistId,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreatePlayList(
IMediator mediator,
CurrentUser currentUser,
CreatePlayListRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId),
cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.Created($"/playlists/{result.Value.Id}", result.Value);
}
private static async Task<IResult> UpdatePlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
UpdatePlayListRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeletePlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, playlistId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestPlayListPictureUpload(
IMediator mediator,
CurrentUser currentUser,
RequestPlayListPictureUploadRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddTrackToPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveTrackFromPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RemoveTrackFromPlayListCommand(currentUser.RequiredId, playlistId, trackId),
cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,141 @@
using Mediator;
using Musify.Application.Tracks.Commands;
using Musify.Application.Tracks.Queries;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
public static class TrackEndpoints
{
public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/tracks")
.WithTags("Tracks");
group.MapGet("/", GetTracks)
.WithName("GetTracks")
.WithSummary("Get Paginated Tracks.");
group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById")
.WithSummary("Get A Track By Id.");
group.MapGet("/{id}/stream", GetTrackStream)
.WithName("GetTrackStream")
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track.")
.RequireAuthorization();
group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.");
group.MapPost("/", CreateTrack)
.WithName("CreateTrack")
.WithSummary("Create A New Track.")
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
.RequireAuthorization();
group.MapDelete("/{trackId}", DeleteTrack)
.WithName("DeleteTrack")
.WithSummary("Delete A Track.")
.RequireAuthorization();
group.MapPost("/upload-urls", RequestTrackUploadUrls)
.WithName("RequestTrackUploadUrls")
.WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.")
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
.RequireAuthorization();
return app;
}
private static async Task<IResult> GetTracks(
IMediator mediator,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetTracksQuery(pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTrackById(
IMediator mediator,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTrackStream(
IMediator mediator,
CurrentUser currentUser,
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.RequiredId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTracksByUserId(
IMediator mediator,
long userId,
CancellationToken cancellationToken,
string? name,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTrack(
IMediator mediator,
CurrentUser currentUser,
CreateTrackRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId),
cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.Created($"/tracks/{result.Value.Id}", result.Value);
}
private static async Task<IResult> DeleteTrack(
IMediator mediator,
CurrentUser currentUser,
Guid trackId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, trackId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestTrackUploadUrls(
IMediator mediator,
CurrentUser currentUser,
RequestTrackUploadUrlsRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RequestTrackUploadUrlsCommand(
currentUser.RequiredId,
request.PictureFileType,
request.PictureContentType,
request.AudioFileType,
request.AudioContentType,
request.ExpectedPictureSizeBytes,
request.ExpectedAudioSizeBytes),
cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,67 @@
using Mediator;
using Musify.Application.Users.Commands;
using Musify.Application.Users.Queries;
using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Extensions;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
public static class UserEndpoints
{
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/users")
.WithTags("Users");
group.MapGet("/", GetUsers)
.WithName("GetUsers")
.WithSummary("Get Paginated Users.");
group.MapGet("/{id}", GetUserById)
.WithName("GetUserById")
.WithSummary("Get A User By Id.");
group.MapPost("/", CreateUser)
.WithName("CreateUser")
.WithSummary("Create A New User.")
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();
return app;
}
private static async Task<IResult> GetUsers(
IMediator mediator,
CancellationToken cancellationToken,
string? usernameSearch,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetUserById(
IMediator mediator,
long id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateUser(
IMediator mediator,
CreateUserRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName),
cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.Created($"/users/{result.Value.Id}", result.Value);
}
}
@@ -0,0 +1,36 @@
using Ardalis.Result;
using IResult = Microsoft.AspNetCore.Http.IResult;
namespace Musify.Api.Extensions;
public static class ResultHttpExtensions
{
public static IResult ToHttpResult(this Result result) =>
result.Status switch
{
ResultStatus.Ok or ResultStatus.NoContent => Results.NoContent(),
ResultStatus.NotFound => Results.NotFound(result.Errors.FirstOrDefault()),
ResultStatus.Invalid => Results.ValidationProblem(ToValidationDictionary(result.ValidationErrors)),
ResultStatus.Unauthorized => Results.Unauthorized(),
ResultStatus.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
ResultStatus.Conflict => Results.Conflict(result.Errors.FirstOrDefault()),
_ => Results.Problem(string.Join(", ", result.Errors))
};
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.Status switch
{
ResultStatus.Ok => Results.Ok(result.Value),
ResultStatus.NotFound => Results.NotFound(result.Errors.FirstOrDefault()),
ResultStatus.Invalid => Results.ValidationProblem(ToValidationDictionary(result.ValidationErrors)),
ResultStatus.Unauthorized => Results.Unauthorized(),
ResultStatus.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
ResultStatus.Conflict => Results.Conflict(result.Errors.FirstOrDefault()),
_ => Results.Problem(string.Join(", ", result.Errors))
};
private static Dictionary<string, string[]> ToValidationDictionary(IEnumerable<ValidationError> errors) =>
errors
.GroupBy(e => string.IsNullOrEmpty(e.Identifier) ? "general" : e.Identifier)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
}
@@ -0,0 +1,34 @@
using FluentValidation;
using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Persistence;
using Musify.Application.Configuration;
using Musify.Infrastructure.Services;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.OpenApi;
using Musify.Api.Scalar;
namespace Musify.Api.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddDatabase(configuration);
services.AddApplicationStorageConfiguration(configuration);
services.AddPlayListConfiguration(configuration);
services.AddTrackConfiguration(configuration);
services.AddStreamGatewayConfiguration(configuration);
services.AddStorageService(configuration);
services.AddStreamTicketService(configuration);
services.AddUploadIntentConfiguration(configuration);
services.AddMassTransitClient(configuration);
services.AddMediator();
services.AddAuthenticationConfiguration(configuration);
services.AddOpenApiConfiguration();
services.AddScalarConfiguration();
services.AddValidatorsFromAssemblyContaining<CreatePlayListRequest>();
return services;
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
namespace Musify.Api.Filters;
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return await next(context);
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
return result.IsValid
? await next(context)
: Results.ValidationProblem(result.ToDictionary());
}
}
+39
View File
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>1c3fa41c-6f07-433b-af79-1f67fad338fd</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ardalis.Result" />
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="MassTransit" />
<PackageReference Include="MassTransit.EntityFrameworkCore" />
<PackageReference Include="MassTransit.RabbitMQ" />
<PackageReference Include="Mediator.Abstractions" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Api" />
<PackageReference Include="OpenTelemetry.Api.ProviderBuilderExtensions" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Musify.Application\Musify.Application.csproj" />
<ProjectReference Include="..\Musify.Domain\Musify.Domain.csproj" />
<ProjectReference Include="..\Musify.Infrastructure\Musify.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.OpenApi;
namespace Musify.Api.OpenApi;
public static class OpenApiExtensions
{
public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services)
{
services.AddOpenApi();
services.ConfigureOptions<OpenApiOptionsSetup>();
return services;
}
}
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Musify.Api.Authentication;
namespace Musify.Api.OpenApi;
public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> options)
: IConfigureNamedOptions<OpenApiOptions>
{
private const string SecuritySchemeId = "OAuth2";
private readonly AuthenticationConfiguration _configuration = options.Value;
public void Configure(string? name, OpenApiOptions options) => Configure(options);
public void Configure(OpenApiOptions options)
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri(_configuration.AuthorizationEndpoint),
TokenUrl = new Uri(_configuration.TokenEndpoint),
Scopes = _configuration.Scopes.ToDictionary(scope => scope, scope => scope),
},
},
};
return Task.CompletedTask;
});
options.AddOperationTransformer((operation, context, _) =>
{
var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata
.OfType<IAuthorizeData>()
.Any();
var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata
.OfType<IAllowAnonymous>()
.Any();
if (!requiresAuthorization || allowsAnonymous)
{
return Task.CompletedTask;
}
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] =
_configuration.Scopes.ToList(),
});
return Task.CompletedTask;
});
}
}
+37
View File
@@ -0,0 +1,37 @@
using Musify.Infrastructure.Observability;
using Scalar.AspNetCore;
using Musify.Api.Endpoints;
using Musify.Api.Extensions;
var builder = WebApplication.CreateBuilder();
const string devCorsPolicy = "dev-cors";
builder.Services
.AddApplicationServices(builder.Configuration)
.AddObservability(builder.Configuration);
if (builder.Environment.IsDevelopment())
{
builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy =>
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
}
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseCors(devCorsPolicy);
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseAuthentication();
app.UseAuthorization();
app
.MapPlayListEndpoints()
.MapTrackEndpoints()
.MapUserEndpoints();
app.Run();
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://192.168.0.120:5111",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,11 @@
namespace Musify.Api.Scalar;
public static class ScalarExtensions
{
public static IServiceCollection AddScalarConfiguration(this IServiceCollection services)
{
services.ConfigureOptions<ScalarOptionsSetup>();
return services;
}
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Options;
using Scalar.AspNetCore;
using Musify.Api.Authentication;
namespace Musify.Api.Scalar;
public sealed class ScalarOptionsSetup(IOptions<AuthenticationConfiguration> options)
: IConfigureOptions<ScalarOptions>
{
private const string SecuritySchemeId = "OAuth2";
private readonly AuthenticationConfiguration _configuration = options.Value;
public void Configure(ScalarOptions scalarOptions)
{
scalarOptions
.WithTitle("Musify API")
.AddPreferredSecuritySchemes(SecuritySchemeId)
.AddAuthorizationCodeFlow(SecuritySchemeId, flow =>
{
flow.ClientId = _configuration.ClientId;
flow.ClientSecret = _configuration.ClientSecret;
flow.AuthorizationUrl = _configuration.AuthorizationEndpoint;
flow.TokenUrl = _configuration.TokenEndpoint;
flow.Pkce = Pkce.Sha256;
flow.SelectedScopes = _configuration.Scopes;
flow.RedirectUri = _configuration.ScalarRedirectUri;
flow.RefreshUrl = _configuration.TokenEndpoint;
} )
.HideModels();
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class CreatePlayListRequestValidator : AbstractValidator<CreatePlayListRequest>
{
public CreatePlayListRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.Description)
.NotEmpty()
.MaximumLength(256);
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator<RequestPlayListPictureUploadRequest>
{
public RequestPlayListPictureUploadRequestValidator()
{
RuleFor(x => x.FileType)
.NotEmpty();
RuleFor(x => x.ContentType)
.NotEmpty();
RuleFor(x => x.ExpectedSizeBytes)
.GreaterThan(0)
.When(x => x.ExpectedSizeBytes.HasValue);
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.PlayLists;
namespace Musify.Api.Validators.PlayLists;
public sealed class UpdatePlayListRequestValidator : AbstractValidator<UpdatePlayListRequest>
{
public UpdatePlayListRequestValidator()
{
RuleFor(x => x.NewName)
.MaximumLength(50)
.When(x => x.NewName is not null);
RuleFor(x => x.NewDescription)
.MaximumLength(256)
.When(x => x.NewDescription is not null);
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks;
public sealed class CreateTrackRequestValidator : AbstractValidator<CreateTrackRequest>
{
public CreateTrackRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.PictureIntentId)
.NotEmpty();
RuleFor(x => x.AudioIntentId)
.NotEmpty();
}
}
@@ -0,0 +1,30 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Tracks;
namespace Musify.Api.Validators.Tracks;
public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator<RequestTrackUploadUrlsRequest>
{
public RequestTrackUploadUrlsRequestValidator()
{
RuleFor(x => x.PictureFileType)
.NotEmpty();
RuleFor(x => x.PictureContentType)
.NotEmpty();
RuleFor(x => x.AudioFileType)
.NotEmpty();
RuleFor(x => x.AudioContentType)
.NotEmpty();
RuleFor(x => x.ExpectedPictureSizeBytes)
.GreaterThan(0)
.When(x => x.ExpectedPictureSizeBytes.HasValue);
RuleFor(x => x.ExpectedAudioSizeBytes)
.GreaterThan(0)
.When(x => x.ExpectedAudioSizeBytes.HasValue);
}
}
@@ -0,0 +1,25 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Users;
namespace Musify.Api.Validators.Users;
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(x => x.Id)
.NotEmpty();
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(48);
RuleFor(x => x.FirstName)
.MaximumLength(48)
.When(x => x.FirstName is not null);
RuleFor(x => x.SecondName)
.MaximumLength(48)
.When(x => x.SecondName is not null);
}
}
+6
View File
@@ -0,0 +1,6 @@
@WebApi_HostAddress = http://localhost:5111
GET {{WebApi_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -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,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Ardalis.Result" />
<PackageReference Include="MassTransit" />
<PackageReference Include="Mediator.Abstractions" />
<PackageReference Include="Mediator.SourceGenerator">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Musify.Domain\Musify.Domain.csproj" />
</ItemGroup>
</Project>
@@ -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);
}
}
@@ -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> > >;
}

Some files were not shown because too many files have changed in this diff Show More