Add editorconfig and analyzers, format backend code
This commit is contained in:
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-4
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-31
@@ -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 ?? []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -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
Reference in New Issue
Block a user