Add editorconfig and analyzers, format backend code

This commit is contained in:
2026-09-24 04:54:17 +02:00
parent 87c3f97529
commit c8fe28b07b
367 changed files with 12179 additions and 12459 deletions
+65
View File
@@ -0,0 +1,65 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{csproj,props,targets,slnx,xml}]
indent_size = 2
[*.json]
indent_size = 4
[**/Migrations/**.cs]
generated_code = true
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_diagnostic.IDE0005.severity = warning
dotnet_style_require_accessibility_modifiers = always:warning
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = when_multiline:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = camel_case
dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case.capitalization = camel_case
dotnet_naming_rule.private_constants.symbols = private_constants
dotnet_naming_rule.private_constants.style = pascal_case
dotnet_naming_rule.private_constants.severity = warning
dotnet_naming_symbols.private_constants.applicable_kinds = field
dotnet_naming_symbols.private_constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Analyzer rules that would need behaviour or API changes; downgraded in P0, revisit in P15.
dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1873.severity = suggestion
dotnet_diagnostic.CA1305.severity = suggestion
dotnet_diagnostic.CA1707.severity = suggestion
dotnet_diagnostic.CA1859.severity = suggestion
dotnet_diagnostic.CA1716.severity = suggestion
dotnet_diagnostic.CA1000.severity = suggestion
dotnet_diagnostic.CA1725.severity = suggestion
dotnet_diagnostic.CA1816.severity = suggestion
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1068.severity = suggestion
dotnet_diagnostic.CA1711.severity = suggestion
dotnet_diagnostic.CA1861.severity = suggestion
@@ -5,78 +5,77 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToAlbumCommandHandler(
IDatabase database,
ILogger<AddTrackToAlbumCommandHandler> logger)
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
{ {
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class AddTrackToAlbumCommandHandler(
IDatabase database,
ILogger<AddTrackToAlbumCommandHandler> logger)
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{ {
var album = await database.Albums logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
.AsNoTracking() return Error.NotFound();
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var track = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
var alreadyAdded = await database.AlbumHasTracks
.AsNoTracking()
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.Conflict(description: "Track is already in the album.");
}
var albumTracksQuery = database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
var nextTrackNumber = hasTracks
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
: 1;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = request.TrackId,
TrackNumber = nextTrackNumber
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
request.TrackId, request.AlbumId, nextTrackNumber);
return new Success();
} }
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var track = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
var alreadyAdded = await database.AlbumHasTracks
.AsNoTracking()
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.Conflict(description: "Track is already in the album.");
}
var albumTracksQuery = database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
var nextTrackNumber = hasTracks
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
: 1;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = request.TrackId,
TrackNumber = nextTrackNumber
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
request.TrackId, request.AlbumId, nextTrackNumber);
return new Success();
} }
} }
@@ -2,127 +2,126 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record CreateAlbumCommand(
long UserId,
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public record CreateAlbumCommand( public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
long UserId,
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
{ {
var userExists = await database.Users logger.LogWarning("User {UserId} not found", request.UserId);
.AsNoTracking() return Error.NotFound(description: $"User {request.UserId} not found");
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
if (pictureIntent.IsError)
return pictureIntent.Errors;
var intent = pictureIntent.Value;
var title = request.Title.Trim();
var album = new Album
{
OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Pictures = new AlbumPictures
{
OriginalName = intent.ObjectName,
SmallName = intent.ObjectName,
MediumName = intent.ObjectName,
LargeName = intent.ObjectName
}
};
await database.Albums.AddAsync(album, cancellationToken);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
}
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
} }
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync( var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
Guid albumId, uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
UploadIntent pictureIntent, if (pictureIntent.IsError)
string finalPictureKey, return pictureIntent.Errors;
CancellationToken cancellationToken)
var intent = pictureIntent.Value;
var title = request.Title.Trim();
var album = new Album
{ {
try OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Pictures = new AlbumPictures
{ {
await eventBus.PublishAsync( OriginalName = intent.ObjectName,
new CreateAlbumResourcesEvent( SmallName = intent.ObjectName,
albumId, MediumName = intent.ObjectName,
pictureIntent.Id, LargeName = intent.ObjectName
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
} }
};
await database.Albums.AddAsync(album, cancellationToken);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
}
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
}
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
} }
} }
} }
@@ -4,47 +4,46 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler(
IDatabase database,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{ {
public record DeleteAlbumCommand(long UserId, Guid AlbumId) public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler(
IDatabase database,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{ {
var album = await database.Albums logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); return Error.NotFound();
if (album == null)
{
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
database.Albums.Remove(album);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
}
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return new Success();
} }
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
database.Albums.Remove(album);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
}
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return new Success();
} }
} }
@@ -4,35 +4,34 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{ {
public record GetAlbumByIdQuery(Guid Id) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) var album = await database.Albums
{ .AsNoTracking()
var album = await database.Albums .Where(a => a.Id == request.Id)
.AsNoTracking() .Select(a => new
.Where(a => a.Id == request.Id) {
.Select(a => new Album = a,
{ TrackCount = a.AlbumTracks.Count,
Album = a, CoverTrackIds = a.AlbumTracks
TrackCount = a.AlbumTracks.Count, .OrderBy(albumTrack => albumTrack.TrackNumber)
CoverTrackIds = a.AlbumTracks .Take(AlbumApplicationResponse.CoverTrackCount)
.OrderBy(albumTrack => albumTrack.TrackNumber) .Select(albumTrack => albumTrack.TrackId)
.Take(AlbumApplicationResponse.CoverTrackCount) .ToList()
.Select(albumTrack => albumTrack.TrackId) })
.ToList() .SingleOrDefaultAsync(cancellationToken);
})
.SingleOrDefaultAsync(cancellationToken);
if (album == null) if (album == null)
return Error.NotFound(); return Error.NotFound();
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds); return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
}
} }
} }
@@ -5,45 +5,44 @@ using MimeMapping;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{ {
public record GetAlbumCoverQuery(Guid AlbumId, string Size) public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{ {
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == request.AlbumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = albumConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{ {
var pictures = await database.Albums.AsNoTracking() "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
.Where(a => a.Id == request.AlbumId) "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
.Select(a => a.Pictures) _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
.SingleOrDefaultAsync(cancellationToken); };
if (pictures == null) if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound(); return Error.NotFound();
var routes = albumConfiguration.Routes; var contentType = MimeUtility.GetMimeMapping(name);
var (name, key) = request.Size.ToLowerInvariant() switch return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
} }
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
} }
@@ -7,43 +7,42 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumTracksQuery(
Guid AlbumId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public record GetAlbumTracksQuery( public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
Guid AlbumId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) var albumExists = await database.Albums
{ .AsNoTracking()
var albumExists = await database.Albums .AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
.AsNoTracking() if (!albumExists)
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken); return Error.NotFound();
if (!albumExists)
return Error.NotFound();
var tracksQuery = database.AlbumHasTracks var tracksQuery = database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Include(albumTrack => albumTrack.Track.Owner) .Include(albumTrack => albumTrack.Track.Owner)
.Include(albumTrack => albumTrack.Track.Tags) .Include(albumTrack => albumTrack.Track.Tags)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId); .Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var totalCount = await tracksQuery.CountAsync(cancellationToken); var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) }) .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)), pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks); return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
} }
} }
@@ -3,58 +3,56 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<ErrorOr<AlbumsSearchResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
{ {
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<AlbumsSearchResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
{ {
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) var albumsQuery = database.Albums
.AsNoTracking()
.AsQueryable();
if (!string.IsNullOrEmpty(request.Title))
{ {
var albumsQuery = database.Albums var normalizedTitle = request.Title.Trim().ToUpperInvariant();
.AsNoTracking() albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
.AsQueryable();
if (!string.IsNullOrEmpty(request.Title))
{
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
}
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(entry => AlbumSearchItemResponse.FromAlbum(
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
.ToList();
return new AlbumsSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
} }
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(entry => AlbumSearchItemResponse.FromAlbum(
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
.ToList();
return new AlbumsSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
} }
} }
@@ -7,47 +7,46 @@ using Musify.Application.Shared;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{ {
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) var userExists = await database.Users
{ .AsNoTracking()
var userExists = await database.Users .AnyAsync(user => user.Id == request.UserId, cancellationToken);
.AsNoTracking() if (!userExists)
.AnyAsync(user => user.Id == request.UserId, cancellationToken); return Error.NotFound(description: $"User {request.UserId} not found");
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
var albumsQuery = database.Albums var albumsQuery = database.Albums
.AsNoTracking() .AsNoTracking()
.Where(album => album.OwnerUserId == request.UserId); .Where(album => album.OwnerUserId == request.UserId);
var totalCount = await albumsQuery.CountAsync(cancellationToken); var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt) .OrderByDescending(album => album.CreatedAt)
.Select(album => new .Select(album => new
{ {
Album = album, Album = album,
TrackCount = album.AlbumTracks.Count, TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount) .Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId) .Select(albumTrack => albumTrack.TrackId)
.ToList() .ToList()
}) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>( var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)), pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums); return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
}
} }
} }
@@ -4,58 +4,57 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{ {
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) var listenedTrackIds = database.ListeningHistories
{ .AsNoTracking()
var listenedTrackIds = database.ListeningHistories .Where(history => history.UserId == request.UserId && history.IsCounted);
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Join( .Join(
listenedTrackIds, listenedTrackIds,
albumTrack => albumTrack.TrackId, albumTrack => albumTrack.TrackId,
history => history.TrackId, history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId) .GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt) .OrderByDescending(entry => entry.LastListenedAt)
.Take(request.Limit) .Take(request.Limit)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (ranked.Count == 0) if (ranked.Count == 0)
return new List<AlbumApplicationResponse>(); return new List<AlbumApplicationResponse>();
var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albums = await database.Albums var albums = await database.Albums
.AsNoTracking() .AsNoTracking()
.Where(album => albumIds.Contains(album.Id)) .Where(album => albumIds.Contains(album.Id))
.Select(album => new .Select(album => new
{ {
Album = album, Album = album,
TrackCount = album.AlbumTracks.Count, TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount) .Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId) .Select(albumTrack => albumTrack.TrackId)
.ToList() .ToList()
}) })
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken); .ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
return ranked return ranked
.Where(entry => albums.ContainsKey(entry.AlbumId)) .Where(entry => albums.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId]) .Select(entry => albums[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)) .Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.ToList(); .ToList();
}
} }
} }
@@ -4,56 +4,55 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{ {
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{ {
var album = await database.Albums logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
.AsNoTracking() return Error.NotFound();
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ToListAsync(cancellationToken);
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
database.AlbumHasTracks.Remove(link);
var trackNumber = 1;
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
remaining.TrackNumber = trackNumber++;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return new Success();
} }
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ToListAsync(cancellationToken);
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
database.AlbumHasTracks.Remove(link);
var trackNumber = 1;
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
remaining.TrackNumber = trackNumber++;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return new Success();
} }
} }
@@ -2,104 +2,103 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record RequestAlbumPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
{ {
public record RequestAlbumPictureUploadCommand( public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{ {
var userExists = await database.Users logger.LogWarning("User {UserId} not found", request.UserId);
.AsNoTracking() return Error.NotFound(description: $"User {request.UserId} not found");
.AnyAsync(u => u.Id == request.UserId, cancellationToken); }
if (!userExists)
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var tempKey = albumConfiguration.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.IsError)
{ {
logger.LogWarning("User {UserId} not found", request.UserId); logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found"); return quotaCheck.Errors;
} }
var effectiveSizeBytes = request.ExpectedSizeBytes var intent = new UploadIntent
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{ {
var uploadUrl = await storageService.GetUploadUrlAsync( UserId = request.UserId,
storageConfiguration.Bucket, Bucket = storageConfiguration.Bucket,
tempKey, Key = tempKey,
request.ContentType, ObjectName = objectName,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), ContentType = request.ContentType,
cancellationToken); ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.AlbumPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await using var transaction = await database.BeginTransactionAsync( await database.UploadIntents.AddAsync(intent, cancellationToken);
System.Data.IsolationLevel.Serializable, cancellationToken); await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent return new AlbumPictureUploadResponse(
{ intent.Id,
UserId = request.UserId, storageConfiguration.Bucket,
Bucket = storageConfiguration.Bucket, tempKey,
Key = tempKey, objectName,
ObjectName = objectName, request.ContentType,
ContentType = request.ContentType, uploadIntentConfiguration.UploadUrlExpiresInSeconds,
ExpectedSizeBytes = effectiveSizeBytes, uploadUrl);
Purpose = UploadIntentPurpose.AlbumPicture, }
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), catch (Exception exception)
}; {
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
await database.UploadIntents.AddAsync(intent, cancellationToken); return Error.Failure(description: "Failed to generate upload URL");
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new AlbumPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
} }
} }
} }
@@ -1,43 +1,42 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Musify.Domain.Entities;
using Musify.Application.Serialization; using Musify.Application.Serialization;
using Musify.Domain.Entities;
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses;
public record AlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{ {
public record AlbumApplicationResponse( public const int CoverTrackCount = 4;
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = 4;
public static AlbumApplicationResponse FromEntity( public static AlbumApplicationResponse FromEntity(
Album album, Album album,
int trackCount, int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null) IReadOnlyList<Guid>? coverTrackIds = null)
{ {
return new AlbumApplicationResponse( return new AlbumApplicationResponse(
album.Id, album.Id,
album.Title, album.Title,
album.Description, album.Description,
album.ReleaseYear, album.ReleaseYear,
album.OwnerUserId, album.OwnerUserId,
trackCount, trackCount,
album.Pictures?.SmallName, album.Pictures?.SmallName,
album.Pictures?.MediumName, album.Pictures?.MediumName,
album.Pictures?.LargeName, album.Pictures?.LargeName,
album.CreatedAt, album.CreatedAt,
album.UpdatedAt, album.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? []);
}
} }
} }
@@ -1,11 +1,10 @@
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses;
{
public record AlbumPictureUploadResponse( public record AlbumPictureUploadResponse(
Guid IntentId, Guid IntentId,
string Bucket, string Bucket,
string Key, string Key,
string PictureName, string PictureName,
string ContentType, string ContentType,
int ExpiresInSeconds, int ExpiresInSeconds,
string UploadUrl); string UploadUrl);
}
@@ -1,7 +1,6 @@
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses;
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
{ {
public record AlbumSearchItemResponse(AlbumApplicationResponse Album) public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
{
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
}
} }
@@ -1,12 +1,11 @@
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses;
{
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items, public record AlbumsSearchResponse(
int PageNumber, IReadOnlyList<AlbumSearchItemResponse> Items,
int PageSize, int PageNumber,
int PageCount, int PageSize,
int TotalItemCount, int PageCount,
bool HasPreviousPage, int TotalItemCount,
bool HasNextPage); bool HasPreviousPage,
} bool HasNextPage);
@@ -2,149 +2,148 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums namespace Musify.Application.Albums;
public record UpdateAlbumCommand(
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public record UpdateAlbumCommand( public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{ {
var album = await database.Albums logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); return Error.NotFound();
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = title.ToUpperInvariant();
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
album.Pictures = new AlbumPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
}
var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
} }
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync( if (album.OwnerUserId != request.UserId)
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{ {
try logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = title.ToUpperInvariant();
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
album.Pictures = new AlbumPictures
{ {
await eventBus.PublishAsync( OriginalName = pictureIntent.ObjectName,
new UpdateAlbumPictureSourceEvent( SmallName = pictureIntent.ObjectName,
albumId, MediumName = pictureIntent.ObjectName,
pictureIntent.Id, LargeName = pictureIntent.ObjectName
storageConfiguration.Bucket, };
pictureIntent.Key, finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
finalPictureKey, }
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath, if (pictureIntent != null && finalPictureKey != null)
albumConfiguration.PicturesSizes.SmallPictureWidth, {
albumConfiguration.PicturesSizes.SmallPictureHeight), var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
new ImageSize( album.Id, pictureIntent, finalPictureKey, cancellationToken);
albumConfiguration.Routes.MediumPicturesPath, if (publishResult.IsError)
albumConfiguration.PicturesSizes.MediumPictureWidth, return publishResult.Errors;
albumConfiguration.PicturesSizes.MediumPictureHeight), }
new ImageSize(
albumConfiguration.Routes.LargePicturesPath, try
albumConfiguration.PicturesSizes.LargePictureWidth, {
albumConfiguration.PicturesSizes.LargePictureHeight)), await database.SaveChangesAsync(cancellationToken);
cancellationToken); }
return new Success(); catch (Exception exception)
} {
catch (Exception exception) logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
{ return Error.Failure(description: $"Failed to update album {request.AlbumId}");
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId); }
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
} var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
} }
} }
} }
@@ -1,4 +1,4 @@
using Mediator; using Mediator;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] [assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
@@ -1,79 +1,78 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class AlbumConfiguration
{ {
public class AlbumConfiguration public const string SectionName = "Album";
{
public const string SectionName = "Album";
[Required] [Required]
public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
[Required] [Required]
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
} }
public class AlbumRoutes public class AlbumRoutes
{ {
[Required] [Required]
public string UploadsFolder { get; set; } = "uploads"; public string UploadsFolder { get; set; } = "uploads";
[Required] [Required]
public string ParentFolders { get; set; } = "Albums"; public string ParentFolders { get; set; } = "Albums";
[Required] [Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required] [Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures"; public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required] [Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures"; public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required] [Required]
public string LargePicturesFolder { get; set; } = "LargePictures"; public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); 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 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 BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
} }
public class AlbumPicturesSizes public class AlbumPicturesSizes
{ {
[Range(1, 1024)] [Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128; public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)] [Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128; public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)] [Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256; public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)] [Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256; public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)] [Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512; public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)] [Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512; public int LargePictureHeight { get; set; } = 512;
}
} }
@@ -1,13 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
{
public class ApplicationStorageConfiguration
{
public const string SectionName = "ApplicationStorage";
[Required] public class ApplicationStorageConfiguration
[StringLength(128, MinimumLength = 1)] {
public required string Bucket { get; set; } public const string SectionName = "ApplicationStorage";
}
[Required]
[StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; }
} }
@@ -1,17 +1,16 @@
using Mediator; using Mediator;
using Musify.Application.Configuration.Responses; using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{ {
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>; ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening,
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) playbackConfiguration.AnonymousFragmentSeconds));
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds));
}
} }
@@ -1,12 +1,11 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
{
public class MixConfiguration
{
public const string SectionName = "Mix";
[Range(4, 100)] public class MixConfiguration
public int ItemsPerMix { get; set; } = 12; {
} public const string SectionName = "Mix";
[Range(4, 100)]
public int ItemsPerMix { get; set; } = 12;
} }
@@ -1,79 +1,78 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class PlayListConfiguration
{ {
public class PlayListConfiguration public const string SectionName = "PlayList";
{
public const string SectionName = "PlayList";
[Required] [Required]
public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
[Required] [Required]
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
} }
public class PlayListRoutes public class PlayListRoutes
{ {
[Required] [Required]
public string UploadsFolder { get; set; } = "uploads"; public string UploadsFolder { get; set; } = "uploads";
[Required] [Required]
public string ParentFolders { get; set; } = "PlayLists"; public string ParentFolders { get; set; } = "PlayLists";
[Required] [Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required] [Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures"; public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required] [Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures"; public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required] [Required]
public string LargePicturesFolder { get; set; } = "LargePictures"; public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); 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 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 BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
} }
public class PlayListPicturesSizes public class PlayListPicturesSizes
{ {
[Range(1, 1024)] [Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128; public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)] [Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128; public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)] [Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256; public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)] [Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256; public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)] [Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512; public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)] [Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512; public int LargePictureHeight { get; set; } = 512;
} }
}
@@ -1,18 +1,17 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public sealed class PlaybackConfiguration
{ {
public const string SectionName = "Playback";
public sealed class PlaybackConfiguration public bool AllowAnonymousListening { get; set; }
{
public const string SectionName = "Playback";
public bool AllowAnonymousListening { get; set; } [Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; }
[Range(0, 3600)] [Range(1, int.MaxValue)]
public int AnonymousFragmentSeconds { get; set; } public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
[Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
} }
@@ -1,5 +1,4 @@
namespace Musify.Application.Configuration.Responses namespace Musify.Application.Configuration.Responses;
{
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
} public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
@@ -1,16 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class StreamGatewayConfiguration
{ {
public class StreamGatewayConfiguration public const string SectionName = "StreamGateway";
{
public const string SectionName = "StreamGateway";
[Url] [Url]
[Required] [Required]
public required string PublicBaseUrl { get; set; } public required string PublicBaseUrl { get; set; }
[Required] [Required]
public string AudioFileName { get; set; } = "audio.m4a"; public string AudioFileName { get; set; } = "audio.m4a";
}
} }
@@ -1,116 +1,115 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class TrackConfiguration
{ {
public class TrackConfiguration public const string SectionName = "Track";
{
public const string SectionName = "Track";
[Required] [Required]
public TrackRoutes Routes { get; set; } = new TrackRoutes(); public TrackRoutes Routes { get; set; } = new TrackRoutes();
[Required] [Required]
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
} }
public class TrackRoutes public class TrackRoutes
{ {
[Required] [Required]
public string UploadsFolder { get; set; } = "uploads"; public string UploadsFolder { get; set; } = "uploads";
[Required] [Required]
public string ParentFolder { get; set; } = "Tracks"; public string ParentFolder { get; set; } = "Tracks";
[Required] [Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required] [Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures"; public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required] [Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures"; public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required] [Required]
public string LargePicturesFolder { get; set; } = "LargePictures"; public string LargePicturesFolder { get; set; } = "LargePictures";
[Required] [Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
[Required] [Required]
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
[Required] [Required]
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
[Required] [Required]
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
[Required] [Required]
public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required] [Required]
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder); public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder); public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder); public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder); public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName); 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 BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName); public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName); public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName); public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName); 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 BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName); public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) => public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
} }
public class TrackPicturesSizes public class TrackPicturesSizes
{ {
[Range(1, 1024)] [Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128; public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)] [Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128; public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)] [Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256; public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)] [Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256; public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)] [Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512; public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)] [Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512; public int LargePictureHeight { get; set; } = 512;
} }
}
@@ -1,42 +1,41 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration namespace Musify.Application.Configuration;
public class UploadIntentConfiguration
{ {
public class UploadIntentConfiguration public const string SectionName = "UploadIntent";
{
public const string SectionName = "UploadIntent";
[Range(10, 3600)] [Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120; public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)] [Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5; public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
[Range(1, long.MaxValue)] [Range(1, long.MaxValue)]
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
[Range(30, 86400)] [Range(30, 86400)]
public int ExpirationJobIntervalSeconds { get; set; } = 300; public int ExpirationJobIntervalSeconds { get; set; } = 300;
[Range(0, 365)] [Range(0, 365)]
public int ExpiredIntentsRetentionDays { get; set; } = 7; public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Required, MinLength(1)] [Required, MinLength(1)]
public string TemporalRootPrefix { get; set; } = "temporal"; public string TemporalRootPrefix { get; set; } = "temporal";
[Range(1, 30)] [Range(1, 30)]
public int TemporalUploadsRetentionDays { get; set; } = 3; public int TemporalUploadsRetentionDays { get; set; } = 3;
[Range(60, 86400)] [Range(60, 86400)]
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
}
} }
@@ -1,4 +1,3 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -1,9 +1,8 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public interface IAudioTranscoderService
{
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); public interface IAudioTranscoderService
} {
} public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
public Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
}
@@ -1,42 +1,41 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IDatabase
{ {
public interface IDatabase public DbSet<User> Users { get; }
{
DbSet<User> Users { get; }
DbSet<PlayList> PlayLists { get; } public DbSet<PlayList> PlayLists { get; }
DbSet<Album> Albums { get; } public DbSet<Album> Albums { get; }
DbSet<AlbumHasTrack> AlbumHasTracks { get; } public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
DbSet<Track> Tracks { get; } public DbSet<Track> Tracks { get; }
DbSet<UserHasTrack> UserHasTracks { get; } public DbSet<UserHasTrack> UserHasTracks { get; }
DbSet<PlayListHasTrack> PlayListHasTracks { get; } public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
DbSet<Mix> Mixes { get; } public DbSet<Mix> Mixes { get; }
DbSet<MixItem> MixItems { get; } public DbSet<MixItem> MixItems { get; }
DbSet<Upload> Uploads { get; } public DbSet<Upload> Uploads { get; }
DbSet<UploadIntent> UploadIntents { get; } public DbSet<UploadIntent> UploadIntents { get; }
DbSet<ListeningHistory> ListeningHistories { get; } public DbSet<ListeningHistory> ListeningHistories { get; }
DbSet<TrackLike> TrackLikes { get; } public DbSet<TrackLike> TrackLikes { get; }
DbSet<TrackTag> TrackTags { get; } public DbSet<TrackTag> TrackTags { get; }
DbSet<UserFollow> UserFollows { get; } public DbSet<UserFollow> UserFollows { get; }
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken); public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
} }
}
@@ -1,7 +1,6 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IDatabaseTransaction : IAsyncDisposable
{ {
public interface IDatabaseTransaction : IAsyncDisposable public Task CommitAsync(CancellationToken cancellationToken = default);
{
Task CommitAsync(CancellationToken cancellationToken = default);
}
} }
@@ -1,7 +1,6 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IEventBus
{ {
public interface IEventBus public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
{ }
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
}
@@ -1,7 +1,6 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IPictureService
{ {
public interface IPictureService public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
{ }
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
}
@@ -1,28 +1,27 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IStorageService
{ {
public interface IStorageService public Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
{
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); public 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); public 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); public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken); public Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
} }
}
@@ -1,10 +1,8 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
public interface IStreamTicketService
{ {
public interface IStreamTicketService public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
{
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
}
public record StreamTicket(string Token, int ExpiresInSeconds);
} }
public record StreamTicket(string Token, int ExpiresInSeconds);
@@ -1,4 +1,3 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public record ObjectMetaData(string ContentType, long ContentLength); public record ObjectMetaData(string ContentType, long ContentLength);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Contracts namespace Musify.Application.Contracts;
{
public record StorageObject(string Key, DateTime LastModifiedUtc); public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record AlbumPictureProcessed(Guid AlbumId); public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record AlbumPictureProcessingFailed(Guid AlbumId); public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,7 +1,6 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record AlbumProcessingFailed( public record AlbumProcessingFailed(
Guid AlbumId, Guid AlbumId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,14 +1,13 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record CreateAlbumResourcesEvent( public record CreateAlbumResourcesEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,14 +1,13 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record CreatePlayListResourcesEvent( public record CreatePlayListResourcesEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,18 +1,17 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record CreateTrackResourcesEvent( public record CreateTrackResourcesEvent(
Guid TrackId, Guid TrackId,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
string AudioSourceKey, string AudioSourceKey,
string AudioDestinationKey, string AudioDestinationKey,
string AudioProcessedFolderKey, string AudioProcessedFolderKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,6 +1,5 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record DeletePlayListEvent( public record DeletePlayListEvent(
Guid PlayListId, Guid PlayListId,
long UserId); long UserId);
}
@@ -1,6 +1,5 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record DeleteTrackEvent( public record DeleteTrackEvent(
Guid TrackId, Guid TrackId,
long UserId); long UserId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record PlayListPictureProcessed(Guid PlayListId); public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record PlayListPictureProcessingFailed(Guid PlayListId); public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -1,7 +1,6 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record PlayListProcessingFailed( public record PlayListProcessingFailed(
Guid PlayListId, Guid PlayListId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackAudioProcessed(Guid TrackId); public record TrackAudioProcessed(Guid TrackId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackAudioProcessingFailed(Guid TrackId); public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackPictureProcessed(Guid TrackId); public record TrackPictureProcessed(Guid TrackId);
}
@@ -1,4 +1,3 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackPictureProcessingFailed(Guid TrackId); public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -1,8 +1,7 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record TrackProcessingFailed( public record TrackProcessingFailed(
Guid TrackId, Guid TrackId,
string? Bucket, string? Bucket,
string? PictureKey, string? PictureKey,
string? AudioKey); string? AudioKey);
}
@@ -1,12 +1,11 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateAlbumPictureEvent( public record UpdateAlbumPictureEvent(
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,14 +1,13 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateAlbumPictureSourceEvent( public record UpdateAlbumPictureSourceEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,12 +1,11 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdatePlayListPictureEvent( public record UpdatePlayListPictureEvent(
Guid PlayListId, Guid PlayListId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,14 +1,13 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdatePlayListPictureSourceEvent( public record UpdatePlayListPictureSourceEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,9 +1,8 @@
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateTrackAudioEvent( public record UpdateTrackAudioEvent(
Guid TrackId, Guid TrackId,
string SourceBucket, string SourceBucket,
string SourceKey, string SourceKey,
string DestinationBucket, string DestinationBucket,
string DestinationFolderKey); string DestinationFolderKey);
}
@@ -1,12 +1,11 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events namespace Musify.Application.Events;
{
public record UpdateTrackPictureEvent( public record UpdateTrackPictureEvent(
Guid TrackId, Guid TrackId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -1,26 +1,25 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres namespace Musify.Application.Genres;
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{ {
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>; public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{ {
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) var all = Enum.GetValues<Genre>();
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse( .Select(genre => new AvailableGenreResponse(
genre, genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList())) all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList(); .ToList();
return ValueTask.FromResult(result); return ValueTask.FromResult(result);
}
} }
} }
@@ -3,29 +3,28 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres namespace Musify.Application.Genres;
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{ {
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>; public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{ {
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken) var rows = await database.TrackTags
{ .AsNoTracking()
var rows = await database.TrackTags .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.AsNoTracking() .GroupBy(tag => tag.Tag)
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.GroupBy(tag => tag.Tag) .ToListAsync(cancellationToken);
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows return rows
.OrderByDescending(row => row.TrackCount) .OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre) .ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount)) .Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList(); .ToList();
}
} }
} }
@@ -7,37 +7,36 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
namespace Musify.Application.Likes namespace Musify.Application.Likes;
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public record GetLikedTracksQuery( public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
long UserId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) var likesQuery = database.TrackLikes
{ .AsNoTracking()
var likesQuery = database.TrackLikes .Include(like => like.Track.Owner)
.AsNoTracking() .Include(like => like.Track.Tags)
.Include(like => like.Track.Owner) .Where(like => like.UserId == request.UserId);
.Include(like => like.Track.Tags)
.Where(like => like.UserId == request.UserId);
var totalCount = await likesQuery.CountAsync(cancellationToken); var totalCount = await likesQuery.CountAsync(cancellationToken);
var pagedEntities = await likesQuery var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt) .OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) }) .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks); return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
} }
} }
@@ -5,52 +5,51 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Likes namespace Musify.Application.Likes;
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{ {
public record ToggleTrackLikeCommand(long UserId, Guid TrackId) public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{ {
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{ {
var trackExists = await database.Tracks logger.LogInformation("Track {TrackId} not found", request.TrackId);
.AsNoTracking() return Error.NotFound(description: $"Track {request.TrackId} not found");
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
bool nowLiked;
if (existingLike != null)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = request.TrackId
}, cancellationToken);
nowLiked = true;
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
return nowLiked;
} }
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
bool nowLiked;
if (existingLike != null)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = request.TrackId
}, cancellationToken);
nowLiked = true;
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
return nowLiked;
} }
} }
@@ -7,146 +7,145 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>;
public class GenerateMixesForUserCommandHandler(
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{ {
public record GenerateMixesForUserCommand(long UserId) private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
: ICommand<ErrorOr<Success>>;
public class GenerateMixesForUserCommandHandler( public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{ {
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds); var userId = request.UserId;
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) var libraryTrackIds = await database.UserHasTracks
.AsNoTracking()
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(userTrack => userTrack.TrackId)
.ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0)
{ {
var userId = request.UserId; logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
var libraryTrackIds = await database.UserHasTracks
.AsNoTracking()
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(userTrack => userTrack.TrackId)
.ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0)
{
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return new Success();
}
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
var historySet = new HashSet<Guid>(historyTrackIds);
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
var drafts = new List<MixDraft>();
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{
drafts.Add(new MixDraft(
"Descubrimiento",
"Canciones de tu biblioteca que todavía no has escuchado.",
discovery));
}
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
{
drafts.Add(new MixDraft(
"Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
daily));
}
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return new Success();
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
}
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return new Success(); return new Success();
} }
private List<Guid> Take(IEnumerable<Guid> trackIds) => var historyTrackIds = await database.ListeningHistories
[.. trackIds.Take(mixConfiguration.ItemsPerMix)]; .AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) => var historySet = new HashSet<Guid>(historyTrackIds);
[.. trackIds.OrderBy(_ => Guid.NewGuid())]; var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second) var drafts = new List<MixDraft>();
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{ {
var merged = new List<Guid>(first.Count + second.Count); drafts.Add(new MixDraft(
for (var index = 0; index < Math.Max(first.Count, second.Count); index++) "Descubrimiento",
{ "Canciones de tu biblioteca que todavía no has escuchado.",
if (index < first.Count) discovery));
merged.Add(first[index]);
if (index < second.Count)
merged.Add(second[index]);
}
return merged;
} }
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken) var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
{ {
var previousMixes = await database.Mixes drafts.Add(new MixDraft(
.Where(mix => mix.UserId == userId) "Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
daily));
}
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return new Success();
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
}
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return new Success();
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
{
var merged = new List<Guid>(first.Count + second.Count);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
{
if (index < first.Count)
merged.Add(first[index]);
if (index < second.Count)
merged.Add(second[index]);
}
return merged;
}
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
{
var previousMixes = await database.Mixes
.Where(mix => mix.UserId == userId)
.ToListAsync(cancellationToken);
if (previousMixes.Count > 0)
{
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
var previousItems = await database.MixItems
.Where(item => previousIds.Contains(item.MixId))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (previousMixes.Count > 0) database.MixItems.RemoveRange(previousItems);
database.Mixes.RemoveRange(previousMixes);
}
for (var position = 0; position < drafts.Count; position++)
{
var draft = drafts[position];
var mix = new Mix
{ {
var previousIds = previousMixes.Select(mix => mix.Id).ToList(); UserId = userId,
var previousItems = await database.MixItems Title = draft.Title,
.Where(item => previousIds.Contains(item.MixId)) Subtitle = draft.Subtitle,
.ToListAsync(cancellationToken); Position = position
};
database.MixItems.RemoveRange(previousItems); await database.Mixes.AddAsync(mix, cancellationToken);
database.Mixes.RemoveRange(previousMixes);
}
for (var position = 0; position < drafts.Count; position++) for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
{ {
var draft = drafts[position]; await database.MixItems.AddAsync(new MixItem
var mix = new Mix
{ {
UserId = userId, MixId = mix.Id,
Title = draft.Title, Position = itemPosition,
Subtitle = draft.Subtitle, TrackId = draft.TrackIds[itemPosition]
Position = position }, cancellationToken);
};
await database.Mixes.AddAsync(mix, cancellationToken);
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
{
await database.MixItems.AddAsync(new MixItem
{
MixId = mix.Id,
Position = itemPosition,
TrackId = draft.TrackIds[itemPosition]
}, cancellationToken);
}
} }
} }
} }
@@ -4,28 +4,27 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{ {
public record GetMixByIdQuery(long UserId, Guid MixId) public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{ {
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) var mix = await database.Mixes
{ .AsNoTracking()
var mix = await database.Mixes .Include(mix => mix.Items)
.AsNoTracking() .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
.Include(mix => mix.Items)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix == null) if (mix == null)
return Error.NotFound(description: $"Mix {request.MixId} was not found"); return Error.NotFound(description: $"Mix {request.MixId} was not found");
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items); return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
}
} }
} }
@@ -4,34 +4,33 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
public record GetMixesByUserIdQuery(long UserId)
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
{ {
public record GetMixesByUserIdQuery(long UserId) public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) var mixes = await database.Mixes
.AsNoTracking()
.Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position)
.ToListAsync(cancellationToken);
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{ {
var mixes = await database.Mixes var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
.AsNoTracking() var coverItems = await MixItemMapper.ToResponsesAsync(
.Where(mix => mix.UserId == request.UserId) database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position)
.ToListAsync(cancellationToken);
var responses = new List<MixApplicationResponse>(mixes.Count); responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
foreach (var mix in mixes)
{
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var coverItems = await MixItemMapper.ToResponsesAsync(
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
}
return responses;
} }
return responses;
} }
} }
@@ -3,38 +3,37 @@ using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Mixes namespace Musify.Application.Mixes;
internal static class MixItemMapper
{ {
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
internal static class MixItemMapper IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
{ {
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync( var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
{
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
var tracksById = await database.Tracks var tracksById = await database.Tracks
.AsNoTracking() .AsNoTracking()
.Include(track => track.Owner) .Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id)) .Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken); .ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems return orderedItems
.Where(item => tracksById.ContainsKey(item.TrackId)) .Where(item => tracksById.ContainsKey(item.TrackId))
.Select(item => .Select(item =>
{ {
var entry = tracksById[item.TrackId]; var entry = tracksById[item.TrackId];
return new MixItemApplicationResponse( return new MixItemApplicationResponse(
item.TrackId, item.TrackId,
entry.Track.Title, entry.Track.Title,
entry.Track.Owner.Name, entry.Track.Owner.Name,
entry.Track.DurationSeconds, entry.Track.DurationSeconds,
entry.ListensCount); entry.ListensCount);
}) })
.ToList(); .ToList();
}
} }
} }
@@ -1,12 +1,11 @@
namespace Musify.Application.Mixes.Responses namespace Musify.Application.Mixes.Responses;
public record MixApplicationResponse(
Guid Id,
string Title,
string? Subtitle,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{ {
public record MixApplicationResponse( public const int CoverItemCount = 4;
Guid Id,
string Title,
string? Subtitle,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{
public const int CoverItemCount = 4;
}
} }
@@ -1,9 +1,8 @@
namespace Musify.Application.Mixes.Responses namespace Musify.Application.Mixes.Responses;
{
public record MixItemApplicationResponse( public record MixItemApplicationResponse(
Guid TrackId, Guid TrackId,
string Title, string Title,
string? Artist, string? Artist,
double DurationSeconds, double DurationSeconds,
int ListensCount); int ListensCount);
}
@@ -5,71 +5,70 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToPlayListCommandHandler(
IDatabase database,
ILogger<AddTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
{ {
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class AddTrackToPlayListCommandHandler(
IDatabase database,
ILogger<AddTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{ {
var playList = await database.PlayLists logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); return Error.NotFound();
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.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 Error.NotFound(description: $"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 Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, 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 new Success();
} }
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.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 Error.NotFound(description: $"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 Error.Conflict(description: "Track is already in the playlist.");
}
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, 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 new Success();
} }
} }
@@ -3,144 +3,143 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Shared; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record CreatePlayListCommand(
long UserId,
string Name,
string? Description,
Guid? PictureIntentId,
PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public record CreatePlayListCommand( private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent);
long UserId,
string Name,
string? Description,
Guid? PictureIntentId,
PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler( public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); var userExists = await database.Users
.AsNoTracking()
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) .AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{ {
var userExists = await database.Users logger.LogWarning("User {UserId} not found", request.UserId);
.AsNoTracking() return Error.NotFound(description: $"User {request.UserId} not found");
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var picturesResult = await ResolvePicturesAsync(request, cancellationToken);
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList
{
UserId = request.UserId,
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
if (resolvedPictures.Intent != null)
{
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
} }
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync( var picturesResult = await ResolvePicturesAsync(request, cancellationToken);
CreatePlayListCommand request, CancellationToken cancellationToken) if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList
{ {
if (!request.PictureIntentId.HasValue) UserId = request.UserId,
{ Name = request.Name,
return new ResolvedPlayListPictures(null, null); NormalizedName = request.Name.Trim().ToUpperInvariant(),
} Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
var validation = await uploadIntentValidator.ValidateAndLoadAsync( if (resolvedPictures.Intent != null)
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); {
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
if (validation.IsError) var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
return validation.Errors; if (publishResult.IsError)
return publishResult.Errors;
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = validation.Value.ObjectName,
SmallName = validation.Value.ObjectName,
MediumName = validation.Value.ObjectName,
LargeName = validation.Value.ObjectName
}, validation.Value);
} }
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync( try
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{ {
try await database.SaveChangesAsync(cancellationToken);
{ }
await eventBus.PublishAsync( catch (Exception exception)
new CreatePlayListResourcesEvent( {
playListId, logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
pictureIntent.Id, return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
storageConfiguration.Bucket, }
pictureIntent.Key,
finalPictureKey, logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
new ImageSize( return PlayListApplicationResponse.FromEntity(playList);
playListConfiguration.Routes.SmallPicturesPath, }
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight), private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
new ImageSize( CreatePlayListCommand request, CancellationToken cancellationToken)
playListConfiguration.Routes.MediumPicturesPath, {
playListConfiguration.PicturesSizes.MediumPictureWidth, if (!request.PictureIntentId.HasValue)
playListConfiguration.PicturesSizes.MediumPictureHeight), {
new ImageSize( return new ResolvedPlayListPictures(null, null);
playListConfiguration.Routes.LargePicturesPath, }
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)), var validation = await uploadIntentValidator.ValidateAndLoadAsync(
cancellationToken); uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
return new Success();
} if (validation.IsError)
catch (Exception exception) return validation.Errors;
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId); return new ResolvedPlayListPictures(new PlayListPictures
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}"); {
} OriginalName = validation.Value.ObjectName,
SmallName = validation.Value.ObjectName,
MediumName = validation.Value.ObjectName,
LargeName = validation.Value.ObjectName
}, validation.Value);
}
private async Task<ErrorOr<Success>> 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 new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
} }
} }
} }
@@ -6,57 +6,56 @@ using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{ {
public record DeletePlayListCommand(long UserId, Guid PlayListId) public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
if (playList == null)
{ {
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
if (playList == null) return Error.NotFound();
{
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.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 Error.Failure(description: $"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 Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
return new Success();
} }
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.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 Error.Failure(description: $"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 Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
return new Success();
} }
} }
@@ -1,42 +1,43 @@
using ErrorOr; using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists.Responses;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{ {
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) var entry = await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == request.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken);
if (entry == null)
return Error.NotFound();
if (entry.PlayList.Visibility == PlaylistVisibility.Private
&& entry.PlayList.UserId != request.RequestingUserId)
{ {
var entry = await database.PlayLists return Error.NotFound();
.AsNoTracking()
.Where(p => p.Id == request.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken);
if (entry == null)
return Error.NotFound();
if (entry.PlayList.Visibility == PlaylistVisibility.Private
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
} }
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
} }
} }
@@ -5,45 +5,44 @@ using MimeMapping;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
: IQuery<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{ {
public record GetPlayListCoverQuery(Guid PlayListId, string Size) public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{ {
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.PlayListId)
.Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = playListConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{ {
var pictures = await database.PlayLists.AsNoTracking() "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
.Where(p => p.Id == request.PlayListId) "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
.Select(p => p.Pictures) _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
.SingleOrDefaultAsync(cancellationToken); };
if (pictures == null) if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound(); return Error.NotFound();
var routes = playListConfiguration.Routes; var contentType = MimeUtility.GetMimeMapping(name);
var (name, key) = request.Size.ToLowerInvariant() switch return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
} }
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
} }
@@ -7,43 +7,42 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public record GetPlayListTracksQuery( public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
Guid PlayListId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) var playListExists = await database.PlayLists
{ .AsNoTracking()
var playListExists = await database.PlayLists .AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
.AsNoTracking() if (!playListExists)
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken); return Error.NotFound();
if (!playListExists)
return Error.NotFound();
var tracksQuery = database.PlayListHasTracks var tracksQuery = database.PlayListHasTracks
.AsNoTracking() .AsNoTracking()
.Include(plt => plt.Track.Owner) .Include(plt => plt.Track.Owner)
.Include(plt => plt.Track.Tags) .Include(plt => plt.Track.Tags)
.Where(plt => plt.PlayListId == request.PlayListId); .Where(plt => plt.PlayListId == request.PlayListId);
var totalCount = await tracksQuery.CountAsync(cancellationToken); var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position) .OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) }) .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks); return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
} }
} }
@@ -1,43 +1,42 @@
using ErrorOr; using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record GetPlayListsQuery(int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{ {
public record GetPlayListsQuery(int PageNumber, int PageSize) public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken) var totalCount = await database.PlayLists.CountAsync(cancellationToken);
{
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedEntities = await database.PlayLists var pagedEntities = await database.PlayLists
.AsNoTracking() .AsNoTracking()
.OrderBy(p => p.Id) .OrderBy(p => p.Id)
.Select(p => new .Select(p => new
{ {
PlayList = p, PlayList = p,
CoverTrackIds = p.PlayListTracks CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position) .OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount) .Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId) .Select(plt => plt.TrackId)
.ToList() .ToList()
}) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>( var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists); return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
} }
} }
@@ -8,57 +8,56 @@ using Musify.Domain.ValueObjects;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record GetPlayListsByUserIdQuery(
long UserId,
string? Name,
int PageNumber,
int PageSize,
bool OnlyPublic = false)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{ {
public record GetPlayListsByUserIdQuery( public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
long UserId,
string? Name,
int PageNumber,
int PageSize,
bool OnlyPublic = false)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
if (request.OnlyPublic)
{ {
var playListsQuery = database.PlayLists playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
if (request.OnlyPublic)
{
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
}
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
}
var totalCount = await playListsQuery.CountAsync(cancellationToken);
var pagedEntities = await playListsQuery
.OrderBy(p => p.CreatedAt)
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
} }
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
}
var totalCount = await playListsQuery.CountAsync(cancellationToken);
var pagedEntities = await playListsQuery
.OrderBy(p => p.CreatedAt)
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
} }
} }
@@ -4,46 +4,45 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{ {
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{ {
var playList = await database.PlayLists logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); return Error.NotFound();
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var link = await database.PlayListHasTracks
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.NotFound(description: "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 new Success();
} }
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var link = await database.PlayListHasTracks
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
return Error.NotFound(description: "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 new Success();
} }
} }
@@ -3,103 +3,102 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record RequestPlayListPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{ {
public record RequestPlayListPictureUploadCommand( public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{ {
var userExists = await database.Users logger.LogWarning("User {UserId} not found", request.UserId);
.AsNoTracking() return Error.NotFound(description: $"User {request.UserId} not found");
.AnyAsync(u => u.Id == request.UserId, cancellationToken); }
if (!userExists)
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.IsError)
{ {
logger.LogWarning("User {UserId} not found", request.UserId); logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found"); return quotaCheck.Errors;
} }
var effectiveSizeBytes = request.ExpectedSizeBytes var intent = new UploadIntent
?? 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( UserId = request.UserId,
storageConfiguration.Bucket, Bucket = storageConfiguration.Bucket,
tempKey, Key = tempKey,
request.ContentType, ObjectName = objectName,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), ContentType = request.ContentType,
cancellationToken); ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.PlayListPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await using var transaction = await database.BeginTransactionAsync( await database.UploadIntents.AddAsync(intent, cancellationToken);
System.Data.IsolationLevel.Serializable, cancellationToken); await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent return new PlayListPictureUploadResponse(
{ intent.Id,
UserId = request.UserId, storageConfiguration.Bucket,
Bucket = storageConfiguration.Bucket, tempKey,
Key = tempKey, objectName,
ObjectName = objectName, request.ContentType,
ContentType = request.ContentType, uploadIntentConfiguration.UploadUrlExpiresInSeconds,
ExpectedSizeBytes = effectiveSizeBytes, uploadUrl);
Purpose = UploadIntentPurpose.PlayListPicture, }
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), catch (Exception exception)
}; {
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
await database.UploadIntents.AddAsync(intent, cancellationToken); return Error.Failure(description: "Failed to generate upload URL");
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 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 Error.Failure(description: "Failed to generate upload URL");
}
} }
} }
} }
@@ -1,37 +1,36 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses namespace Musify.Application.PlayLists.Responses;
{
public record PlayListApplicationResponse(
Guid Id,
string Name,
string? Description,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
PlaylistVisibility Visibility,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = 4;
public static PlayListApplicationResponse FromEntity( public record PlayListApplicationResponse(
PlayList playList, Guid Id,
IReadOnlyList<Guid>? coverTrackIds = null) string Name,
{ string? Description,
return new PlayListApplicationResponse( string? SmallImageKeyName,
playList.Id, string? MediumImageKeyName,
playList.Name, string? LargeImageKeyName,
playList.Description, PlaylistVisibility Visibility,
playList.Pictures?.SmallName, DateTime CreatedAt,
playList.Pictures?.MediumName, DateTime UpdatedAt,
playList.Pictures?.LargeName, IReadOnlyList<Guid> CoverTrackIds)
playList.Visibility, {
playList.CreatedAt, public const int CoverTrackCount = 4;
playList.UpdatedAt,
coverTrackIds ?? []); public static PlayListApplicationResponse FromEntity(
} PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
} }
} }
@@ -1,11 +1,10 @@
namespace Musify.Application.PlayLists.Responses namespace Musify.Application.PlayLists.Responses;
{
public record PlayListPictureUploadResponse( public record PlayListPictureUploadResponse(
Guid IntentId, Guid IntentId,
string Bucket, string Bucket,
string Key, string Key,
string PictureName, string PictureName,
string ContentType, string ContentType,
int ExpiresInSeconds, int ExpiresInSeconds,
string UploadUrl); string UploadUrl);
}
@@ -3,153 +3,152 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Shared; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists;
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public record UpdatePlayListCommand( public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
long UserId,
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playListEntity == null)
{ {
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
if (playListEntity == null) return Error.NotFound();
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playListEntity.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
if (!string.IsNullOrWhiteSpace(request.NewDescription))
{
playListEntity.Description = request.NewDescription;
}
if (request.NewVisibility.HasValue)
{
playListEntity.Visibility = request.NewVisibility.Value;
}
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
playListEntity.Pictures = new PlayListPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
}
var coverTrackIds = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
} }
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync( if (playListEntity.UserId != request.UserId)
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{ {
try logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
if (!string.IsNullOrWhiteSpace(request.NewDescription))
{
playListEntity.Description = request.NewDescription;
}
if (request.NewVisibility.HasValue)
{
playListEntity.Visibility = request.NewVisibility.Value;
}
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
playListEntity.Pictures = new PlayListPictures
{ {
await eventBus.PublishAsync( OriginalName = pictureIntent.ObjectName,
new UpdatePlayListPictureSourceEvent( SmallName = pictureIntent.ObjectName,
playListId, MediumName = pictureIntent.ObjectName,
pictureIntent.Id, LargeName = pictureIntent.ObjectName
storageConfiguration.Bucket, };
pictureIntent.Key, finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
finalPictureKey, }
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath, if (pictureIntent != null && finalPictureKey != null)
playListConfiguration.PicturesSizes.SmallPictureWidth, {
playListConfiguration.PicturesSizes.SmallPictureHeight), var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
new ImageSize( playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
playListConfiguration.Routes.MediumPicturesPath, if (publishResult.IsError)
playListConfiguration.PicturesSizes.MediumPictureWidth, return publishResult.Errors;
playListConfiguration.PicturesSizes.MediumPictureHeight), }
new ImageSize(
playListConfiguration.Routes.LargePicturesPath, try
playListConfiguration.PicturesSizes.LargePictureWidth, {
playListConfiguration.PicturesSizes.LargePictureHeight)), await database.SaveChangesAsync(cancellationToken);
cancellationToken); }
return new Success(); catch (Exception exception)
} {
catch (Exception exception) logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
{ return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId); }
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
} var coverTrackIds = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
}
private async Task<ErrorOr<Success>> 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 new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
} }
} }
} }
@@ -1,9 +1,9 @@
using ErrorOr; using ErrorOr;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Services; namespace Musify.Application.Services;
@@ -1,7 +1,6 @@
namespace Musify.Application.Shared namespace Musify.Application.Shared;
{
public record ImageSize( public record ImageSize(
string SavePath, string SavePath,
int Width, int Width,
int Height); int Height);
}
@@ -1,26 +1,25 @@
using X.PagedList; using X.PagedList;
namespace Musify.Application.Shared namespace Musify.Application.Shared;
public record PaginatedResponse<T>(
IReadOnlyCollection<T> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasNextPage,
bool HasPreviousPage)
{ {
public record PaginatedResponse<T>( public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
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(),
return new PaginatedResponse<T>( pagedList.PageNumber,
pagedList.ToArray(), pagedList.PageSize,
pagedList.PageNumber, pagedList.PageCount,
pagedList.PageSize, pagedList.TotalItemCount,
pagedList.PageCount, pagedList.HasNextPage,
pagedList.TotalItemCount, pagedList.HasPreviousPage);
pagedList.HasNextPage,
pagedList.HasPreviousPage);
}
} }
} }
@@ -1,12 +1,11 @@
namespace Musify.Application.Shared namespace Musify.Application.Shared;
public static class StorageKey
{ {
public static class StorageKey public static string Combine(params string[] segments)
{ {
public static string Combine(params string[] segments) return string.Join('/', segments
{ .Where(static s => !string.IsNullOrWhiteSpace(s))
return string.Join('/', segments .Select(static s => s.Trim().Trim('/', '\\')));
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
}
} }
} }
@@ -3,159 +3,158 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Shared; using Musify.Application.Contracts;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Tracks.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record CreateTrackCommand(
long UserId,
string Title,
Guid PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
{ {
public record CreateTrackCommand( public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
long UserId,
string Title,
Guid PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
{ {
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken) var user = await database.Users
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{ {
var user = await database.Users logger.LogWarning("User {UserId} not found", request.UserId);
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); return Error.NotFound(description: $"User {request.UserId} not found");
if (user == null)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var distinctTags = request.Tags.Distinct().ToList();
if (distinctTags.Count == 0)
{
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
return Error.Validation(description: "At least one tag is required.");
}
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
if (tagConflicts.Count > 0)
{
var conflict = tagConflicts.First();
logger.LogWarning(
"Track {Title} was submitted with incompatible tags {First} and {Second}",
request.Title, conflict.First, conflict.Second);
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
}
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.PictureIntentId, request.UserId, cancellationToken);
if (pictureValidation.IsError)
return pictureValidation.Errors;
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.AudioIntentId, request.UserId, cancellationToken);
if (audioValidation.IsError)
return audioValidation.Errors;
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(),
OwnerUserId = user.Id,
Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
},
Audio = new TrackAudio
{
OriginalName = audioIntent.ObjectName,
TranscodeStatus = ProcessingStatus.Pending
}
};
trackEntity.Tags = distinctTags
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
.ToList();
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 Error.Failure(description: $"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 Error.Failure(description: $"Failed to create track for {request.Title}");
}
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
} }
var distinctTags = request.Tags.Distinct().ToList();
if (distinctTags.Count == 0)
{
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
return Error.Validation(description: "At least one tag is required.");
}
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
if (tagConflicts.Count > 0)
{
var conflict = tagConflicts.First();
logger.LogWarning(
"Track {Title} was submitted with incompatible tags {First} and {Second}",
request.Title, conflict.First, conflict.Second);
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
}
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.PictureIntentId, request.UserId, cancellationToken);
if (pictureValidation.IsError)
return pictureValidation.Errors;
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.AudioIntentId, request.UserId, cancellationToken);
if (audioValidation.IsError)
return audioValidation.Errors;
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(),
OwnerUserId = user.Id,
Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = trackConfiguration.Routes.PresetSmallPicture,
MediumName = trackConfiguration.Routes.PresetMediumPicture,
LargeName = trackConfiguration.Routes.PresetLargePicture,
ProcessingStatus = ProcessingStatus.Pending
},
Audio = new TrackAudio
{
OriginalName = audioIntent.ObjectName,
TranscodeStatus = ProcessingStatus.Pending
}
};
trackEntity.Tags = distinctTags
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
.ToList();
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 Error.Failure(description: $"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 Error.Failure(description: $"Failed to create track for {request.Title}");
}
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
} }
} }
@@ -3,31 +3,30 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record DeleteStaleUncountedListensCommand : ICommand<int>;
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{ {
public record DeleteStaleUncountedListensCommand : ICommand<int>; public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{ {
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
{
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
var stale = await database.ListeningHistories var stale = await database.ListeningHistories
.Where(l => !l.IsCounted && l.ListenedAt < cutoff) .Where(l => !l.IsCounted && l.ListenedAt < cutoff)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (stale.Count == 0) if (stale.Count == 0)
return 0; return 0;
database.ListeningHistories.RemoveRange(stale); database.ListeningHistories.RemoveRange(stale);
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays); logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
return stale.Count; return stale.Count;
}
} }
} }
@@ -2,70 +2,68 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Events;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record DeleteTrackCommand(
long UserId,
Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class DeleteTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteTrackCommandHandler> logger)
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
{ {
public record DeleteTrackCommand( public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
long UserId,
Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class DeleteTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteTrackCommandHandler> logger)
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
{ {
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); logger.LogInformation("Track {TrackId} not found", request.TrackId);
if (track == null) return Error.NotFound();
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound();
}
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
if (track.Audio.IsInProgress)
{
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
return Error.Conflict(description: "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 Error.Failure(description: $"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 Error.Failure(description: $"Failed to delete track {request.TrackId}");
}
return new Success();
} }
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
if (track.Audio.IsInProgress)
{
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
return Error.Conflict(description: "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 Error.Failure(description: $"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 Error.Failure(description: $"Failed to delete track {request.TrackId}");
}
return new Success();
} }
} }
@@ -1,30 +1,29 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
{ {
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>; public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
{ {
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) var track = await database.ListeningHistories
{ .AsNoTracking()
var track = await database.ListeningHistories .Include(t => t.Track.Owner)
.AsNoTracking() .Include(t => t.Track.Tags)
.Include(t => t.Track.Owner) .Where(t => t.UserId == query.UserId)
.Include(t => t.Track.Tags) .OrderByDescending(t => t.ListenedAt)
.Where(t => t.UserId == query.UserId) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
.OrderByDescending(t => t.ListenedAt) .FirstOrDefaultAsync(cancellationToken);
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
.FirstOrDefaultAsync(cancellationToken);
return track == null return track == null
? Error.NotFound("Track.NotFound", "No track found for the given user ID.") ? Error.NotFound("Track.NotFound", "No track found for the given user ID.")
: track; : track;
}
} }
} }
@@ -1,40 +1,39 @@
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>;
public class GetListeningHistoryQueryHandler(
IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
{ {
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>; public const int ListSize = 15;
public class GetListeningHistoryQueryHandler( public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
{ {
public const int ListSize = 15; var recentTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == query.UserId && l.IsCounted)
.GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt))
.Select(g => g.Key)
.Take(ListSize)
.ToListAsync(cancellationToken);
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) var tracksById = await database.Tracks
{ .AsNoTracking()
var recentTrackIds = await database.ListeningHistories .Include(t => t.Owner)
.AsNoTracking() .Include(t => t.Tags)
.Where(l => l.UserId == query.UserId && l.IsCounted) .Where(t => recentTrackIds.Contains(t.Id))
.GroupBy(l => l.TrackId) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.OrderByDescending(g => g.Max(l => l.ListenedAt)) .ToDictionaryAsync(t => t.Id, cancellationToken);
.Select(g => g.Key)
.Take(ListSize)
.ToListAsync(cancellationToken);
var tracksById = await database.Tracks return recentTrackIds
.AsNoTracking() .Select(id => tracksById[id])
.Include(t => t.Owner) .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
.Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds
.Select(id => tracksById[id])
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
}
} }
} }
@@ -4,27 +4,26 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record GetTrackByIdQuery(Guid TrackId)
: IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetTrackByIdQueryHandler(IDatabase database)
: IQueryHandler<GetTrackByIdQuery, ErrorOr<TrackApplicationResponse>>
{ {
public record GetTrackByIdQuery(Guid TrackId) public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<TrackApplicationResponse>>;
public class GetTrackByIdQueryHandler(IDatabase database)
: IQueryHandler<GetTrackByIdQuery, ErrorOr<TrackApplicationResponse>>
{ {
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) var entity = await database.Tracks.AsNoTracking()
{ .Include(t => t.Owner)
var entity = await database.Tracks.AsNoTracking() .Include(t => t.Tags)
.Include(t => t.Owner) .Where(t => t.Id == request.TrackId)
.Include(t => t.Tags) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.Where(t => t.Id == request.TrackId) .SingleOrDefaultAsync(cancellationToken);
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.SingleOrDefaultAsync(cancellationToken);
if (entity == null) if (entity == null)
return Error.NotFound(); return Error.NotFound();
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount); return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
}
} }
} }
@@ -4,50 +4,49 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record GetTrackCoverQuery(Guid TrackId, string Size)
: IQuery<ErrorOr<TrackCoverLocation>>;
public record TrackCoverLocation(string Bucket, string Key, string ContentType);
public class GetTrackCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: IQueryHandler<GetTrackCoverQuery, ErrorOr<TrackCoverLocation>>
{ {
public record GetTrackCoverQuery(Guid TrackId, string Size) public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<TrackCoverLocation>>;
public record TrackCoverLocation(string Bucket, string Key, string ContentType);
public class GetTrackCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: IQueryHandler<GetTrackCoverQuery, ErrorOr<TrackCoverLocation>>
{ {
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
var routes = trackConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{ {
var track = await database.Tracks.AsNoTracking() "small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)),
.Where(t => t.Id == request.TrackId) "large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)),
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) _ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName))
.SingleOrDefaultAsync(cancellationToken); };
if (track == null) if (string.IsNullOrEmpty(name) || key == null)
return Error.NotFound(); return Error.NotFound();
var routes = trackConfiguration.Routes; var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
var (name, key) = request.Size.ToLowerInvariant() switch ? "image/webp"
{ : name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
"small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)), ? "image/png"
"large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)), : "image/jpeg";
_ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName))
};
if (string.IsNullOrEmpty(name) || key == null) return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
return Error.NotFound();
var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
? "image/webp"
: name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
? "image/png"
: "image/jpeg";
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
} }
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
@@ -7,43 +7,42 @@ using Musify.Application.Contracts;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
: IQuery<ErrorOr<TrackStreamResponse>>;
public class GetTrackStreamQueryHandler(
IDatabase database,
TrackStreamIssuer streamIssuer,
PlaybackConfiguration playbackConfiguration,
ILogger<GetTrackStreamQueryHandler> logger)
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
{ {
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
: IQuery<ErrorOr<TrackStreamResponse>>;
public class GetTrackStreamQueryHandler(
IDatabase database,
TrackStreamIssuer streamIssuer,
PlaybackConfiguration playbackConfiguration,
ILogger<GetTrackStreamQueryHandler> logger)
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
{ {
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.Id, t.Audio })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
if (!track.Audio.IsProcessed)
{ {
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); return Error.Conflict(description: "Track audio is not available for streaming yet.");
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Select(t => new { t.Id, t.Audio })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
if (!track.Audio.IsProcessed)
{
logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
return Error.Conflict(description: "Track audio is not available for streaming yet.");
}
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)");
return response;
} }
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)");
return response;
} }
} }
@@ -1,57 +1,55 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using X.PagedList.EF;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
{ {
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
{ {
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken) var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.AsQueryable();
if (!string.IsNullOrEmpty(request.Name))
{ {
var tracksQuery = database.UserHasTracks var normalizedName = request.Name.Trim().ToUpperInvariant();
.AsNoTracking() tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.AsQueryable();
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
.ToList();
return new TracksSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
} }
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
.ToList();
return new TracksSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
} }
} }
@@ -1,53 +1,52 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record GetTracksByUserIdQuery(
long UserId,
string? Name,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetTracksByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public record GetTracksByUserIdQuery( public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
long UserId,
string? Name,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetTracksByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<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.Owner)
.Include(ut => ut.Track.Tags)
.Where(p => p.UserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name))
{ {
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; var normalizedName = request.Name.Trim().ToUpperInvariant();
var pageSize = request.PageSize < 1 ? 10 : request.PageSize; tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.Where(p => p.UserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
} }
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
} }
} }
@@ -1,14 +1,13 @@
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
{
public static class ListeningRules
{
public const double CountedThresholdSeconds = 30;
public const double CountedThresholdFraction = 0.5;
public const double DurationToleranceSeconds = 2;
public const double ClockToleranceSeconds = 5;
public const int UncountedRetentionDays = 8;
public static double CountedThreshold(double durationSeconds) => public static class ListeningRules
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); {
} public const double CountedThresholdSeconds = 30;
public const double CountedThresholdFraction = 0.5;
public const double DurationToleranceSeconds = 2;
public const double ClockToleranceSeconds = 5;
public const int UncountedRetentionDays = 8;
public static double CountedThreshold(double durationSeconds) =>
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
} }
@@ -3,42 +3,41 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record RecordListeningProgressCommand(
long UserId,
Guid ListenId,
double PlayedSeconds)
: ICommand<ErrorOr<Success>>;
public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{ {
public record RecordListeningProgressCommand( public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
long UserId,
Guid ListenId,
double PlayedSeconds)
: ICommand<ErrorOr<Success>>;
public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
{ return Error.Validation(description: "playedSeconds must be a non-negative number.");
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
return Error.Validation(description: "playedSeconds must be a non-negative number.");
var listen = await database.ListeningHistories var listen = await database.ListeningHistories
.Include(l => l.Track) .Include(l => l.Track)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
if (listen == null) if (listen == null)
return Error.NotFound(); return Error.NotFound();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds; var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap)); var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted); listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
listen.LastProgressAt = now; listen.LastProgressAt = now;
listen.IsCounted = listen.IsCounted listen.IsCounted = listen.IsCounted
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds); || listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
return Result.Success; return Result.Success;
}
} }
} }
@@ -3,141 +3,140 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks;
public record RequestTrackUploadUrlsCommand(
long UserId,
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null)
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
public class RequestTrackUploadUrlsCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
{ {
public record RequestTrackUploadUrlsCommand( public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
long UserId,
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType,
long? ExpectedPictureSizeBytes = null,
long? ExpectedAudioSizeBytes = null)
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
public class RequestTrackUploadUrlsCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
{ {
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{ {
var userExists = await database.Users logger.LogWarning("User {UserId} not found", request.UserId);
.AsNoTracking() return Error.NotFound(description: $"User {request.UserId} not found");
.AnyAsync(u => u.Id == request.UserId, cancellationToken); }
if (!userExists)
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.IsError)
{ {
logger.LogWarning("User {UserId} not found", request.UserId); logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found"); return quotaCheck.Errors;
} }
var effectivePictureSize = request.ExpectedPictureSizeBytes var pictureIntent = new UploadIntent
?? 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); UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempPictureKey,
ObjectName = pictureObjectName,
ContentType = request.PictureContentType,
ExpectedSizeBytes = effectivePictureSize,
Purpose = UploadIntentPurpose.TrackPicture,
ExpiresAt = expiresAt,
};
var pictureUploadUrl = await storageService.GetUploadUrlAsync( var audioIntent = new UploadIntent
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.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
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 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); UserId = request.UserId,
return Error.Failure(description: "Failed to generate upload URLs"); 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 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 Error.Failure(description: "Failed to generate upload URLs");
} }
} }
} }

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