Change editor rule
This commit is contained in:
@@ -7,71 +7,72 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTracksToAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTracksToAlbumCommandHandler> logger)
|
||||
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
||||
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTracksToAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTracksToAlbumCommandHandler> logger)
|
||||
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (album.IsError)
|
||||
return album.Errors;
|
||||
|
||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||
|
||||
var tracks = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => new { track.Id, track.OwnerUserId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
||||
if (missing != Guid.Empty)
|
||||
return AppErrors.NotFound("Track", missing);
|
||||
|
||||
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||
if (foreign != null)
|
||||
return AppErrors.Forbidden("Track", foreign.Id);
|
||||
|
||||
var alreadyInAlbum = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
||||
.Select(link => link.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Album track numbers are user-visible, so they start at 1.
|
||||
var nextTrackNumber = (await database.AlbumHasTracks
|
||||
.Where(link => link.AlbumId == request.AlbumId)
|
||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||
|
||||
foreach (var trackId in toAdd)
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (album.IsError)
|
||||
return album.Errors;
|
||||
|
||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||
|
||||
var tracks = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => new { track.Id, track.OwnerUserId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
||||
if (missing != Guid.Empty)
|
||||
return AppErrors.NotFound("Track", missing);
|
||||
|
||||
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||
if (foreign != null)
|
||||
return AppErrors.Forbidden("Track", foreign.Id);
|
||||
|
||||
var alreadyInAlbum = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
||||
.Select(link => link.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Album track numbers are user-visible, so they start at 1.
|
||||
var nextTrackNumber = (await database.AlbumHasTracks
|
||||
.Where(link => link.AlbumId == request.AlbumId)
|
||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||
|
||||
foreach (var trackId in toAdd)
|
||||
{
|
||||
AlbumId = request.AlbumId,
|
||||
TrackId = trackId,
|
||||
TrackNumber = nextTrackNumber++
|
||||
}, cancellationToken);
|
||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||
{
|
||||
AlbumId = request.AlbumId,
|
||||
TrackId = trackId,
|
||||
TrackNumber = nextTrackNumber++
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,61 +12,62 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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,
|
||||
AlbumConfiguration albumConfiguration)
|
||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
||||
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,
|
||||
AlbumConfiguration albumConfiguration)
|
||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", request.UserId);
|
||||
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
|
||||
var picture = prepared.Value;
|
||||
var title = request.Title.Trim();
|
||||
|
||||
var album = new Album
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
OwnerUserId = request.UserId,
|
||||
Title = title,
|
||||
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||
Description = request.Description,
|
||||
ReleaseYear = request.ReleaseYear,
|
||||
Pictures = picture.Pictures
|
||||
};
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", request.UserId);
|
||||
|
||||
await database.Albums.AddAsync(album, cancellationToken);
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
|
||||
await eventBus.PublishAsync(
|
||||
new CreateAlbumResourcesEvent(
|
||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
var picture = prepared.Value;
|
||||
var title = request.Title.Trim();
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
var album = new Album
|
||||
{
|
||||
OwnerUserId = request.UserId,
|
||||
Title = title,
|
||||
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||
Description = request.Description,
|
||||
ReleaseYear = request.ReleaseYear,
|
||||
Pictures = picture.Pictures
|
||||
};
|
||||
|
||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
||||
await database.Albums.AddAsync(album, cancellationToken);
|
||||
|
||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
||||
await eventBus.PublishAsync(
|
||||
new CreateAlbumResourcesEvent(
|
||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
||||
|
||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,31 +6,32 @@ using Musify.Application.Events;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class DeleteAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeleteAlbumCommandHandler> logger)
|
||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class DeleteAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeleteAlbumCommandHandler> logger)
|
||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var album = found.Value;
|
||||
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
var album = found.Value;
|
||||
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
|
||||
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
|
||||
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
|
||||
|
||||
return Result.Success;
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,26 @@ using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
public record GetAlbumByIdQuery(Guid Id)
|
||||
: IQuery<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class GetAlbumByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.Albums
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(a => a.Id == request.Id)
|
||||
.SelectResponse()
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
public record GetAlbumByIdQuery(Guid Id)
|
||||
: IQuery<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
return album == null
|
||||
? AppErrors.NotFound("Album", request.Id)
|
||||
: album;
|
||||
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()
|
||||
.Active()
|
||||
.Where(a => a.Id == request.Id)
|
||||
.SelectResponse()
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return album == null
|
||||
? AppErrors.NotFound("Album", request.Id)
|
||||
: album;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,33 +6,34 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
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>>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var albumExists = await database.Albums
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||
if (!albumExists)
|
||||
return AppErrors.NotFound("Album", request.AlbumId);
|
||||
public record GetAlbumTracksQuery(
|
||||
Guid AlbumId,
|
||||
int PageNumber,
|
||||
int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
|
||||
return await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.ThenBy(albumTrack => albumTrack.Id)
|
||||
.Select(albumTrack => albumTrack.Track)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
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()
|
||||
.Active()
|
||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||
if (!albumExists)
|
||||
return AppErrors.NotFound("Album", request.AlbumId);
|
||||
|
||||
return await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.ThenBy(albumTrack => albumTrack.Id)
|
||||
.Select(albumTrack => albumTrack.Track)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,30 +4,31 @@ using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
||||
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
||||
|
||||
public class GetAlbumsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
||||
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
||||
|
||||
public class GetAlbumsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
|
||||
{
|
||||
var albumsQuery = database.Albums
|
||||
.AsNoTracking()
|
||||
.Active();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedTitle = TextNormalizer.Normalize(request.Title);
|
||||
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
||||
}
|
||||
var albumsQuery = database.Albums
|
||||
.AsNoTracking()
|
||||
.Active();
|
||||
|
||||
return await albumsQuery
|
||||
.OrderByDescending(album => album.CreatedAt)
|
||||
.ThenBy(album => album.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
var normalizedTitle = TextNormalizer.Normalize(request.Title);
|
||||
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
||||
}
|
||||
|
||||
return await albumsQuery
|
||||
.OrderByDescending(album => album.CreatedAt)
|
||||
.ThenBy(album => album.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,29 +5,30 @@ using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
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>>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", request.UserId);
|
||||
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
|
||||
|
||||
return await database.Albums
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(album => album.OwnerUserId == request.UserId)
|
||||
.OrderByDescending(album => album.CreatedAt)
|
||||
.ThenBy(album => album.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
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()
|
||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", request.UserId);
|
||||
|
||||
return await database.Albums
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(album => album.OwnerUserId == request.UserId)
|
||||
.OrderByDescending(album => album.CreatedAt)
|
||||
.ThenBy(album => album.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,49 +3,50 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
||||
|
||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
||||
|
||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
|
||||
{
|
||||
var listens = database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
||||
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var listens = database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
||||
|
||||
var ranked = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||
.Join(
|
||||
listens,
|
||||
albumTrack => albumTrack.TrackId,
|
||||
history => history.TrackId,
|
||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
||||
.GroupBy(entry => entry.AlbumId)
|
||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
||||
.OrderByDescending(entry => entry.LastListenedAt)
|
||||
.ThenBy(entry => entry.AlbumId)
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
var ranked = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||
.Join(
|
||||
listens,
|
||||
albumTrack => albumTrack.TrackId,
|
||||
history => history.TrackId,
|
||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
||||
.GroupBy(entry => entry.AlbumId)
|
||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
||||
.OrderByDescending(entry => entry.LastListenedAt)
|
||||
.ThenBy(entry => entry.AlbumId)
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (ranked.Count == 0)
|
||||
return [];
|
||||
if (ranked.Count == 0)
|
||||
return [];
|
||||
|
||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||
|
||||
var albumsById = await database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(album => albumIds.Contains(album.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
||||
var albumsById = await database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(album => albumIds.Contains(album.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
||||
|
||||
return ranked
|
||||
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
||||
.Select(entry => albumsById[entry.AlbumId])
|
||||
.ToList();
|
||||
return ranked
|
||||
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
||||
.Select(entry => albumsById[entry.AlbumId])
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,41 +5,42 @@ using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
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>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
||||
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class RemoveTrackFromAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
|
||||
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (album.IsError)
|
||||
return album.Errors;
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (album.IsError)
|
||||
return album.Errors;
|
||||
|
||||
var albumTracks = await database.AlbumHasTracks
|
||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
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)
|
||||
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
||||
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
||||
if (link == null)
|
||||
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
||||
|
||||
database.AlbumHasTracks.Remove(link);
|
||||
database.AlbumHasTracks.Remove(link);
|
||||
|
||||
var trackNumber = 1;
|
||||
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
|
||||
remaining.TrackNumber = trackNumber++;
|
||||
var trackNumber = 1;
|
||||
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
|
||||
remaining.TrackNumber = trackNumber++;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
||||
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
||||
|
||||
return Result.Success;
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
public record RequestAlbumPictureUploadCommand(
|
||||
long UserId,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null)
|
||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||
|
||||
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
|
||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||
await issuer.IssuePictureAsync(
|
||||
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
|
||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||
public record RequestAlbumPictureUploadCommand(
|
||||
long UserId,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null)
|
||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||
|
||||
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
|
||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||
await issuer.IssuePictureAsync(
|
||||
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
|
||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Albums.Responses;
|
||||
|
||||
public record AlbumApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
long OwnerUserId,
|
||||
int TrackCount,
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
string? LargeImageKeyName,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds)
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||
|
||||
public static AlbumApplicationResponse FromEntity(
|
||||
Album album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
public record AlbumApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
long OwnerUserId,
|
||||
int TrackCount,
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
string? LargeImageKeyName,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds)
|
||||
{
|
||||
return new AlbumApplicationResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
album.OwnerUserId,
|
||||
trackCount,
|
||||
album.Pictures?.SmallName,
|
||||
album.Pictures?.MediumName,
|
||||
album.Pictures?.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? []);
|
||||
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||
|
||||
public static AlbumApplicationResponse FromEntity(
|
||||
Album album,
|
||||
int trackCount,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
return new AlbumApplicationResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
album.OwnerUserId,
|
||||
trackCount,
|
||||
album.Pictures?.SmallName,
|
||||
album.Pictures?.MediumName,
|
||||
album.Pictures?.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums.Responses;
|
||||
|
||||
public static class AlbumProjections
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
public const int CoverTrackCount = 4;
|
||||
public static class AlbumProjections
|
||||
{
|
||||
public const int CoverTrackCount = 4;
|
||||
|
||||
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
|
||||
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
|
||||
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
|
||||
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
|
||||
query.Select(album => new AlbumApplicationResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
album.OwnerUserId,
|
||||
album.AlbumTracks.Count,
|
||||
album.Pictures!.SmallName,
|
||||
album.Pictures!.MediumName,
|
||||
album.Pictures!.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
album.AlbumTracks
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(CoverTrackCount)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToList()));
|
||||
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
|
||||
query.Select(album => new AlbumApplicationResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
album.OwnerUserId,
|
||||
album.AlbumTracks.Count,
|
||||
album.Pictures!.SmallName,
|
||||
album.Pictures!.MediumName,
|
||||
album.Pictures!.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
album.AlbumTracks
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(CoverTrackCount)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToList()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,63 +11,64 @@ using Musify.Application.Services;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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,
|
||||
AlbumConfiguration albumConfiguration)
|
||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||
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,
|
||||
AlbumConfiguration albumConfiguration)
|
||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var album = found.Value;
|
||||
var title = request.Title.Trim();
|
||||
|
||||
album.Title = title;
|
||||
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||
album.Description = request.Description;
|
||||
album.ReleaseYear = request.ReleaseYear;
|
||||
|
||||
if (request.NewPictureIntentId is { } intentId)
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var picture = prepared.Value;
|
||||
album.Pictures = picture.Pictures;
|
||||
var album = found.Value;
|
||||
var title = request.Title.Trim();
|
||||
|
||||
await eventBus.PublishAsync(
|
||||
new UpdateAlbumPictureSourceEvent(
|
||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
album.Title = title;
|
||||
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||
album.Description = request.Description;
|
||||
album.ReleaseYear = request.ReleaseYear;
|
||||
|
||||
if (request.NewPictureIntentId is { } intentId)
|
||||
{
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
|
||||
var picture = prepared.Value;
|
||||
album.Pictures = picture.Pictures;
|
||||
|
||||
await eventBus.PublishAsync(
|
||||
new UpdateAlbumPictureSourceEvent(
|
||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
||||
|
||||
return await database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id == album.Id)
|
||||
.SelectResponse()
|
||||
.SingleAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
||||
|
||||
return await database.Albums
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id == album.Id)
|
||||
.SelectResponse()
|
||||
.SingleAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,26 +3,27 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Services;
|
||||
|
||||
namespace Musify.Application;
|
||||
|
||||
public static class ApplicationDependencyInjection
|
||||
namespace Musify.Application
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
||||
public static class ApplicationDependencyInjection
|
||||
{
|
||||
services.AddMediator();
|
||||
services.AddScoped<UploadIntentValidator>();
|
||||
services.AddScoped<UploadIntentIssuer>();
|
||||
services.AddScoped<TrackStreamIssuer>();
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddMediator();
|
||||
services.AddScoped<UploadIntentValidator>();
|
||||
services.AddScoped<UploadIntentIssuer>();
|
||||
services.AddScoped<TrackStreamIssuer>();
|
||||
|
||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
|
||||
services.AddValidatedOptions<PlayListConfiguration>(configuration);
|
||||
services.AddValidatedOptions<AlbumConfiguration>(configuration);
|
||||
services.AddValidatedOptions<TrackConfiguration>(configuration);
|
||||
services.AddValidatedOptions<MixConfiguration>(configuration);
|
||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
|
||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
|
||||
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
|
||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
|
||||
services.AddValidatedOptions<PlayListConfiguration>(configuration);
|
||||
services.AddValidatedOptions<AlbumConfiguration>(configuration);
|
||||
services.AddValidatedOptions<TrackConfiguration>(configuration);
|
||||
services.AddValidatedOptions<MixConfiguration>(configuration);
|
||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
|
||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
|
||||
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
|
||||
|
||||
return services;
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "Album";
|
||||
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||
{
|
||||
public static string SectionName => "Album";
|
||||
|
||||
[Required]
|
||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
|
||||
[Required]
|
||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
|
||||
|
||||
[Required]
|
||||
public PictureSizes PicturesSizes { get; set; } = new();
|
||||
[Required]
|
||||
public PictureSizes PicturesSizes { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public class ApplicationStorageConfiguration : IConfigurationOptions
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "ApplicationStorage";
|
||||
public class ApplicationStorageConfiguration : IConfigurationOptions
|
||||
{
|
||||
public static string SectionName => "ApplicationStorage";
|
||||
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 1)]
|
||||
public required string Bucket { get; set; }
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 1)]
|
||||
public required string Bucket { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,29 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public static class ConfigurationDependencyInjection
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
|
||||
where T : class, IConfigurationOptions =>
|
||||
services.AddValidatedOptions<T>(configuration, T.SectionName);
|
||||
|
||||
public static IServiceCollection AddValidatedOptions<T>(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName)
|
||||
where T : class
|
||||
public static class ConfigurationDependencyInjection
|
||||
{
|
||||
services
|
||||
.AddOptionsWithValidateOnStart<T>()
|
||||
.Bind(configuration.GetRequiredSection(sectionName))
|
||||
.ValidateDataAnnotations();
|
||||
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
|
||||
where T : class, IConfigurationOptions =>
|
||||
services.AddValidatedOptions<T>(configuration, T.SectionName);
|
||||
|
||||
services.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<T>>().Value);
|
||||
public static IServiceCollection AddValidatedOptions<T>(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName)
|
||||
where T : class
|
||||
{
|
||||
services
|
||||
.AddOptionsWithValidateOnStart<T>()
|
||||
.Bind(configuration.GetRequiredSection(sectionName))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
return services;
|
||||
services.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<T>>().Value);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
|
||||
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
||||
|
||||
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
||||
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(new PlaybackPublicConfigResponse(
|
||||
playbackConfiguration.AllowAnonymousListening,
|
||||
playbackConfiguration.AnonymousFragmentSeconds));
|
||||
|
||||
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
||||
|
||||
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
||||
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
|
||||
{
|
||||
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(new PlaybackPublicConfigResponse(
|
||||
playbackConfiguration.AllowAnonymousListening,
|
||||
playbackConfiguration.AnonymousFragmentSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public interface IConfigurationOptions
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static abstract string SectionName { get; }
|
||||
public interface IConfigurationOptions
|
||||
{
|
||||
public static abstract string SectionName { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public class MixConfiguration : IConfigurationOptions
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "Mix";
|
||||
public class MixConfiguration : IConfigurationOptions
|
||||
{
|
||||
public static string SectionName => "Mix";
|
||||
|
||||
[Range(4, 100)]
|
||||
public int ItemsPerMix { get; set; } = 12;
|
||||
[Range(4, 100)]
|
||||
public int ItemsPerMix { get; set; } = 12;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,90 +2,91 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public enum PictureSize
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
Small,
|
||||
Medium,
|
||||
Large
|
||||
}
|
||||
|
||||
public class PictureRoutes
|
||||
{
|
||||
[Required]
|
||||
public string UploadsFolder { get; set; } = "uploads";
|
||||
|
||||
[Required]
|
||||
public string ParentFolder { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
||||
|
||||
[Required]
|
||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
||||
|
||||
[Required]
|
||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
||||
|
||||
[Required]
|
||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||
|
||||
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
|
||||
public enum PictureSize
|
||||
{
|
||||
PictureSize.Small => SmallPicturesFolder,
|
||||
PictureSize.Medium => MediumPicturesFolder,
|
||||
PictureSize.Large => LargePicturesFolder,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
|
||||
});
|
||||
Small,
|
||||
Medium,
|
||||
Large
|
||||
}
|
||||
|
||||
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
|
||||
public class PictureRoutes
|
||||
{
|
||||
[Required]
|
||||
public string UploadsFolder { get; set; } = "uploads";
|
||||
|
||||
public string BuildOriginalPicturePath(long userId, string pictureName) =>
|
||||
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
|
||||
[Required]
|
||||
public string ParentFolder { get; set; } = string.Empty;
|
||||
|
||||
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
|
||||
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
|
||||
}
|
||||
|
||||
public class PictureSizes
|
||||
{
|
||||
[Range(1, 1024)]
|
||||
public int SmallWidth { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int SmallHeight { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumWidth { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumHeight { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargeWidth { get; set; } = 512;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargeHeight { get; set; } = 512;
|
||||
|
||||
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
|
||||
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
|
||||
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
|
||||
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
|
||||
}
|
||||
|
||||
public interface IPictureOwnerConfiguration
|
||||
{
|
||||
public PictureRoutes Routes { get; }
|
||||
|
||||
public PictureSizes PicturesSizes { get; }
|
||||
}
|
||||
|
||||
public static class PictureSizeParser
|
||||
{
|
||||
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
||||
public static PictureSize Parse(string? value) =>
|
||||
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||
? size
|
||||
: PictureSize.Medium;
|
||||
[Required]
|
||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
||||
|
||||
[Required]
|
||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
||||
|
||||
[Required]
|
||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
||||
|
||||
[Required]
|
||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||
|
||||
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
|
||||
{
|
||||
PictureSize.Small => SmallPicturesFolder,
|
||||
PictureSize.Medium => MediumPicturesFolder,
|
||||
PictureSize.Large => LargePicturesFolder,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
|
||||
});
|
||||
|
||||
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
|
||||
|
||||
public string BuildOriginalPicturePath(long userId, string pictureName) =>
|
||||
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
|
||||
|
||||
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
|
||||
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
|
||||
}
|
||||
|
||||
public class PictureSizes
|
||||
{
|
||||
[Range(1, 1024)]
|
||||
public int SmallWidth { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int SmallHeight { get; set; } = 128;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumWidth { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int MediumHeight { get; set; } = 256;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargeWidth { get; set; } = 512;
|
||||
|
||||
[Range(1, 1024)]
|
||||
public int LargeHeight { get; set; } = 512;
|
||||
|
||||
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
|
||||
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
|
||||
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
|
||||
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
|
||||
}
|
||||
|
||||
public interface IPictureOwnerConfiguration
|
||||
{
|
||||
public PictureRoutes Routes { get; }
|
||||
|
||||
public PictureSizes PicturesSizes { get; }
|
||||
}
|
||||
|
||||
public static class PictureSizeParser
|
||||
{
|
||||
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
||||
public static PictureSize Parse(string? value) =>
|
||||
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||
? size
|
||||
: PictureSize.Medium;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "PlayList";
|
||||
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||
{
|
||||
public static string SectionName => "PlayList";
|
||||
|
||||
[Required]
|
||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
|
||||
[Required]
|
||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
|
||||
|
||||
[Required]
|
||||
public PictureSizes PicturesSizes { get; set; } = new();
|
||||
[Required]
|
||||
public PictureSizes PicturesSizes { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
|
||||
public sealed class PlaybackConfiguration : IConfigurationOptions
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "Playback";
|
||||
|
||||
public bool AllowAnonymousListening { get; set; }
|
||||
public sealed class PlaybackConfiguration : IConfigurationOptions
|
||||
{
|
||||
public static string SectionName => "Playback";
|
||||
|
||||
[Range(0, 3600)]
|
||||
public int AnonymousFragmentSeconds { get; set; }
|
||||
public bool AllowAnonymousListening { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
||||
[Range(0, 3600)]
|
||||
public int AnonymousFragmentSeconds { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,4 +1,5 @@
|
||||
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,15 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public class StreamGatewayConfiguration : IConfigurationOptions
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "StreamGateway";
|
||||
public class StreamGatewayConfiguration : IConfigurationOptions
|
||||
{
|
||||
public static string SectionName => "StreamGateway";
|
||||
|
||||
[Url]
|
||||
[Required]
|
||||
public required string PublicBaseUrl { get; set; }
|
||||
[Url]
|
||||
[Required]
|
||||
public required string PublicBaseUrl { get; set; }
|
||||
|
||||
[Required]
|
||||
public string AudioFileName { get; set; } = "audio.m4a";
|
||||
[Required]
|
||||
public string AudioFileName { get; set; } = "audio.m4a";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,55 +2,56 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "Track";
|
||||
|
||||
[Required]
|
||||
public TrackRoutes Routes { get; set; } = new();
|
||||
|
||||
[Required]
|
||||
public PictureSizes PicturesSizes { get; set; } = new();
|
||||
|
||||
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
|
||||
}
|
||||
|
||||
public class TrackRoutes : PictureRoutes
|
||||
{
|
||||
public TrackRoutes()
|
||||
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||
{
|
||||
ParentFolder = "Tracks";
|
||||
public static string SectionName => "Track";
|
||||
|
||||
[Required]
|
||||
public TrackRoutes Routes { get; set; } = new();
|
||||
|
||||
[Required]
|
||||
public PictureSizes PicturesSizes { get; set; } = new();
|
||||
|
||||
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
|
||||
}
|
||||
|
||||
[Required]
|
||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||
public class TrackRoutes : PictureRoutes
|
||||
{
|
||||
public TrackRoutes()
|
||||
{
|
||||
ParentFolder = "Tracks";
|
||||
}
|
||||
|
||||
[Required]
|
||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
||||
[Required]
|
||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
||||
[Required]
|
||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
||||
[Required]
|
||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
||||
|
||||
[Required]
|
||||
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
||||
[Required]
|
||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
||||
|
||||
[Required]
|
||||
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
|
||||
[Required]
|
||||
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
||||
|
||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
||||
[Required]
|
||||
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
|
||||
|
||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
|
||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
||||
|
||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
|
||||
|
||||
public string BuildOriginalAudioPath(long userId, string audioName) =>
|
||||
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
|
||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
||||
|
||||
public string BuildProcessedAudioPath(string folderName) =>
|
||||
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
|
||||
public string BuildOriginalAudioPath(long userId, string audioName) =>
|
||||
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
|
||||
|
||||
public string BuildProcessedAudioPath(string folderName) =>
|
||||
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Musify.Application.Configuration;
|
||||
|
||||
public class UploadIntentConfiguration : IConfigurationOptions
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
public static string SectionName => "UploadIntent";
|
||||
public class UploadIntentConfiguration : IConfigurationOptions
|
||||
{
|
||||
public static string SectionName => "UploadIntent";
|
||||
|
||||
[Range(10, 3600)]
|
||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||
[Range(10, 3600)]
|
||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
|
||||
[Range(1, 100)]
|
||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
|
||||
[Range(1, long.MaxValue)]
|
||||
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
|
||||
|
||||
[Range(30, 86400)]
|
||||
public int ExpirationJobIntervalSeconds { get; set; } = 300;
|
||||
[Range(30, 86400)]
|
||||
public int ExpirationJobIntervalSeconds { get; set; } = 300;
|
||||
|
||||
[Range(0, 365)]
|
||||
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
||||
[Range(0, 365)]
|
||||
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
||||
|
||||
[Required, MinLength(1)]
|
||||
public string TempRootPrefix { get; set; } = "temp";
|
||||
[Required, MinLength(1)]
|
||||
public string TempRootPrefix { get; set; } = "temp";
|
||||
|
||||
[Range(1, 30)]
|
||||
public int TempUploadsRetentionDays { get; set; } = 3;
|
||||
[Range(1, 30)]
|
||||
public int TempUploadsRetentionDays { get; set; } = 3;
|
||||
|
||||
[Range(60, 86400)]
|
||||
public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
|
||||
[Range(60, 86400)]
|
||||
public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public interface IAudioTranscoderService
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
||||
public interface IAudioTranscoderService
|
||||
{
|
||||
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,42 @@ using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public interface IDatabase
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public DbSet<User> Users { get; }
|
||||
public interface IDatabase
|
||||
{
|
||||
public DbSet<User> Users { get; }
|
||||
|
||||
public DbSet<PlayList> PlayLists { get; }
|
||||
public DbSet<PlayList> PlayLists { get; }
|
||||
|
||||
public DbSet<Album> Albums { get; }
|
||||
public DbSet<Album> Albums { get; }
|
||||
|
||||
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||
|
||||
public DbSet<Track> Tracks { get; }
|
||||
public DbSet<Track> Tracks { get; }
|
||||
|
||||
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||
|
||||
public DbSet<Mix> Mixes { get; }
|
||||
public DbSet<Mix> Mixes { get; }
|
||||
|
||||
public DbSet<MixItem> MixItems { get; }
|
||||
public DbSet<MixItem> MixItems { get; }
|
||||
|
||||
public DbSet<UploadIntent> UploadIntents { get; }
|
||||
public DbSet<UploadIntent> UploadIntents { get; }
|
||||
|
||||
public DbSet<ListeningHistory> ListeningHistories { get; }
|
||||
public DbSet<ListeningHistory> ListeningHistories { get; }
|
||||
|
||||
public DbSet<TrackLike> TrackLikes { get; }
|
||||
public DbSet<TrackLike> TrackLikes { get; }
|
||||
|
||||
public DbSet<TrackTag> TrackTags { get; }
|
||||
public DbSet<TrackTag> TrackTags { get; }
|
||||
|
||||
public DbSet<UserFollow> UserFollows { get; }
|
||||
public DbSet<UserFollow> UserFollows { get; }
|
||||
|
||||
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||
|
||||
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
|
||||
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
|
||||
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public interface IDatabaseTransaction : IAsyncDisposable
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public Task CommitAsync(CancellationToken cancellationToken = default);
|
||||
public interface IDatabaseTransaction : IAsyncDisposable
|
||||
{
|
||||
public Task CommitAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public interface IEventBus
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
||||
public interface IEventBus
|
||||
{
|
||||
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public interface IPictureService
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
||||
public interface IPictureService
|
||||
{
|
||||
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public interface IStorageService
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
||||
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
public interface IStorageService
|
||||
{
|
||||
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
||||
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
|
||||
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
|
||||
|
||||
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
public 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);
|
||||
|
||||
public 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);
|
||||
|
||||
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
||||
CancellationToken cancellationToken);
|
||||
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public interface IStreamTicketService
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
||||
}
|
||||
public interface IStreamTicketService
|
||||
{
|
||||
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
||||
}
|
||||
|
||||
public record StreamTicket(string Token, int ExpiresInSeconds);
|
||||
public record StreamTicket(string Token, int ExpiresInSeconds);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public record ObjectMetaData(string ContentType, long ContentLength);
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public record ObjectMetaData(string ContentType, long ContentLength);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Contracts;
|
||||
|
||||
public record StorageObject(string Key, DateTime LastModifiedUtc);
|
||||
namespace Musify.Application.Contracts
|
||||
{
|
||||
public record StorageObject(string Key, DateTime LastModifiedUtc);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record AlbumPictureProcessed(Guid AlbumId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record AlbumPictureProcessed(Guid AlbumId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record AlbumPictureProcessingFailed(Guid AlbumId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record AlbumPictureProcessingFailed(Guid AlbumId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record AlbumProcessingFailed(
|
||||
Guid AlbumId,
|
||||
string? Bucket,
|
||||
string? PictureKey);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record AlbumProcessingFailed(
|
||||
Guid AlbumId,
|
||||
string? Bucket,
|
||||
string? PictureKey);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record CreateAlbumResourcesEvent(
|
||||
Guid AlbumId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record CreateAlbumResourcesEvent(
|
||||
Guid AlbumId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record CreatePlayListResourcesEvent(
|
||||
Guid PlayListId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record CreatePlayListResourcesEvent(
|
||||
Guid PlayListId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record CreateTrackResourcesEvent(
|
||||
Guid TrackId,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
string AudioSourceKey,
|
||||
string AudioDestinationKey,
|
||||
string AudioProcessedFolderKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record CreateTrackResourcesEvent(
|
||||
Guid TrackId,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
string AudioSourceKey,
|
||||
string AudioDestinationKey,
|
||||
string AudioProcessedFolderKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record DeleteAlbumEvent(
|
||||
Guid AlbumId,
|
||||
long UserId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record DeleteAlbumEvent(
|
||||
Guid AlbumId,
|
||||
long UserId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record DeletePlayListEvent(
|
||||
Guid PlayListId,
|
||||
long UserId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record DeletePlayListEvent(
|
||||
Guid PlayListId,
|
||||
long UserId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record DeleteTrackEvent(
|
||||
Guid TrackId,
|
||||
long UserId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record DeleteTrackEvent(
|
||||
Guid TrackId,
|
||||
long UserId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record PlayListPictureProcessed(Guid PlayListId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record PlayListPictureProcessed(Guid PlayListId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record PlayListPictureProcessingFailed(Guid PlayListId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record PlayListPictureProcessingFailed(Guid PlayListId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record PlayListProcessingFailed(
|
||||
Guid PlayListId,
|
||||
string? Bucket,
|
||||
string? PictureKey);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record PlayListProcessingFailed(
|
||||
Guid PlayListId,
|
||||
string? Bucket,
|
||||
string? PictureKey);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record TrackAudioProcessed(Guid TrackId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record TrackAudioProcessed(Guid TrackId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record TrackAudioProcessingFailed(Guid TrackId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record TrackAudioProcessingFailed(Guid TrackId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record TrackPictureProcessed(Guid TrackId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record TrackPictureProcessed(Guid TrackId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record TrackPictureProcessingFailed(Guid TrackId);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record TrackPictureProcessingFailed(Guid TrackId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record TrackProcessingFailed(
|
||||
Guid TrackId,
|
||||
string? Bucket,
|
||||
string? PictureKey,
|
||||
string? AudioKey);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record TrackProcessingFailed(
|
||||
Guid TrackId,
|
||||
string? Bucket,
|
||||
string? PictureKey,
|
||||
string? AudioKey);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record UpdateAlbumPictureEvent(
|
||||
Guid AlbumId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdateAlbumPictureEvent(
|
||||
Guid AlbumId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record UpdateAlbumPictureSourceEvent(
|
||||
Guid AlbumId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdateAlbumPictureSourceEvent(
|
||||
Guid AlbumId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record UpdatePlayListPictureEvent(
|
||||
Guid PlayListId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdatePlayListPictureEvent(
|
||||
Guid PlayListId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record UpdatePlayListPictureSourceEvent(
|
||||
Guid PlayListId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdatePlayListPictureSourceEvent(
|
||||
Guid PlayListId,
|
||||
Guid PictureIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record UpdateTrackAudioEvent(
|
||||
Guid TrackId,
|
||||
string SourceBucket,
|
||||
string SourceKey,
|
||||
string DestinationBucket,
|
||||
string DestinationFolderKey);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdateTrackAudioEvent(
|
||||
Guid TrackId,
|
||||
string SourceBucket,
|
||||
string SourceKey,
|
||||
string DestinationBucket,
|
||||
string DestinationFolderKey);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Events;
|
||||
|
||||
public record UpdateTrackPictureEvent(
|
||||
Guid TrackId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSizes Sizes);
|
||||
namespace Musify.Application.Events
|
||||
{
|
||||
public record UpdateTrackPictureEvent(
|
||||
Guid TrackId,
|
||||
string Bucket,
|
||||
string SourceKey,
|
||||
ImageSizes Sizes);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,24 @@ using Mediator;
|
||||
using Musify.Application.Genres.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres;
|
||||
|
||||
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||
|
||||
public class GetAvailableGenresQueryHandler
|
||||
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||
namespace Musify.Application.Genres
|
||||
{
|
||||
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
||||
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||
|
||||
public class GetAvailableGenresQueryHandler
|
||||
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||
{
|
||||
var all = Enum.GetValues<Genre>();
|
||||
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var all = Enum.GetValues<Genre>();
|
||||
|
||||
IReadOnlyList<AvailableGenreResponse> result = all
|
||||
.Select(genre => new AvailableGenreResponse(
|
||||
genre,
|
||||
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
||||
.ToList();
|
||||
IReadOnlyList<AvailableGenreResponse> result = all
|
||||
.Select(genre => new AvailableGenreResponse(
|
||||
genre,
|
||||
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult(result);
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,27 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Genres.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres;
|
||||
|
||||
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||
|
||||
public class GetGenresQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
||||
namespace Musify.Application.Genres
|
||||
{
|
||||
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.TrackTags
|
||||
.AsNoTracking()
|
||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.GroupBy(tag => tag.Tag)
|
||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.TrackCount)
|
||||
.ThenBy(row => row.Genre)
|
||||
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
||||
.ToList();
|
||||
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()
|
||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.GroupBy(tag => tag.Tag)
|
||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.TrackCount)
|
||||
.ThenBy(row => row.Genre)
|
||||
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres.Responses;
|
||||
|
||||
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
||||
namespace Musify.Application.Genres.Responses
|
||||
{
|
||||
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres.Responses;
|
||||
|
||||
public record GenreResponse(Genre Genre, int TrackCount);
|
||||
namespace Musify.Application.Genres.Responses
|
||||
{
|
||||
public record GenreResponse(Genre Genre, int TrackCount);
|
||||
}
|
||||
|
||||
@@ -5,26 +5,27 @@ using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Likes;
|
||||
|
||||
public record GetLikedTracksQuery(
|
||||
long UserId,
|
||||
int PageNumber,
|
||||
int PageSize)
|
||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||
|
||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||
namespace Musify.Application.Likes
|
||||
{
|
||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
||||
public record GetLikedTracksQuery(
|
||||
long UserId,
|
||||
int PageNumber,
|
||||
int PageSize)
|
||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||
|
||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||
{
|
||||
return await database.TrackLikes
|
||||
.AsNoTracking()
|
||||
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.OrderByDescending(like => like.CreatedAt)
|
||||
.ThenBy(like => like.Id)
|
||||
.Select(like => like.Track)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await database.TrackLikes
|
||||
.AsNoTracking()
|
||||
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.OrderByDescending(like => like.CreatedAt)
|
||||
.ThenBy(like => like.Id)
|
||||
.Select(like => like.Track)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,52 +7,53 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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>>
|
||||
namespace Musify.Application.Likes
|
||||
{
|
||||
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
||||
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
|
||||
: ICommand<ErrorOr<bool>>;
|
||||
|
||||
public class ToggleTrackLikeCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<ToggleTrackLikeCommandHandler> logger)
|
||||
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
|
||||
{
|
||||
var trackExists = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||
if (!trackExists)
|
||||
return AppErrors.NotFound("Track", request.TrackId);
|
||||
|
||||
var existingLike = await database.TrackLikes
|
||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
||||
|
||||
bool nowLiked;
|
||||
if (existingLike != null)
|
||||
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
database.TrackLikes.Remove(existingLike);
|
||||
nowLiked = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
await database.TrackLikes.AddAsync(new TrackLike
|
||||
var trackExists = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||
if (!trackExists)
|
||||
return AppErrors.NotFound("Track", request.TrackId);
|
||||
|
||||
var existingLike = await database.TrackLikes
|
||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
||||
|
||||
bool nowLiked;
|
||||
if (existingLike != null)
|
||||
{
|
||||
UserId = request.UserId,
|
||||
TrackId = request.TrackId
|
||||
}, cancellationToken);
|
||||
nowLiked = true;
|
||||
database.TrackLikes.Remove(existingLike);
|
||||
nowLiked = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
await database.TrackLikes.AddAsync(new TrackLike
|
||||
{
|
||||
UserId = request.UserId,
|
||||
TrackId = request.TrackId
|
||||
}, cancellationToken);
|
||||
nowLiked = true;
|
||||
}
|
||||
|
||||
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return true;
|
||||
|
||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
||||
|
||||
return nowLiked;
|
||||
}
|
||||
|
||||
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return true;
|
||||
|
||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
||||
|
||||
return nowLiked;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,124 +6,125 @@ using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
public record GenerateMixesForUserCommand(long UserId)
|
||||
: ICommand;
|
||||
|
||||
public class GenerateMixesForUserCommandHandler(
|
||||
IDatabase database,
|
||||
MixConfiguration mixConfiguration,
|
||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||
: ICommandHandler<GenerateMixesForUserCommand>
|
||||
namespace Musify.Application.Mixes
|
||||
{
|
||||
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
||||
public record GenerateMixesForUserCommand(long UserId)
|
||||
: ICommand;
|
||||
|
||||
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||
public class GenerateMixesForUserCommandHandler(
|
||||
IDatabase database,
|
||||
MixConfiguration mixConfiguration,
|
||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||
: ICommandHandler<GenerateMixesForUserCommand>
|
||||
{
|
||||
var userId = request.UserId;
|
||||
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
||||
|
||||
var libraryTrackIds = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => track.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (libraryTrackIds.Count == 0)
|
||||
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||
var userId = request.UserId;
|
||||
|
||||
var libraryTrackIds = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => track.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (libraryTrackIds.Count == 0)
|
||||
{
|
||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
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(MixKind.Discovery, discovery));
|
||||
}
|
||||
|
||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||
if (daily.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
||||
}
|
||||
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
var historyTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == userId && history.IsCounted)
|
||||
.Select(history => history.TrackId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
||||
|
||||
var historySet = new HashSet<Guid>(historyTrackIds);
|
||||
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
||||
|
||||
var drafts = new List<MixDraft>();
|
||||
|
||||
var discovery = Take(Shuffle(unheardTrackIds));
|
||||
if (discovery.Count > 0)
|
||||
private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
|
||||
{
|
||||
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
|
||||
}
|
||||
|
||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||
if (daily.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
||||
}
|
||||
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
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(List<Guid> first, List<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)
|
||||
{
|
||||
database.Mixes.RemoveRange(previousMixes);
|
||||
}
|
||||
|
||||
for (var position = 0; position < drafts.Count; position++)
|
||||
{
|
||||
var draft = drafts[position];
|
||||
var mix = new Mix
|
||||
var merged = new List<Guid>(first.Count + second.Count);
|
||||
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
||||
{
|
||||
UserId = userId,
|
||||
Kind = draft.Kind,
|
||||
Position = position
|
||||
};
|
||||
if (index < first.Count)
|
||||
merged.Add(first[index]);
|
||||
if (index < second.Count)
|
||||
merged.Add(second[index]);
|
||||
}
|
||||
|
||||
await database.Mixes.AddAsync(mix, cancellationToken);
|
||||
return merged;
|
||||
}
|
||||
|
||||
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
||||
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)
|
||||
{
|
||||
await database.MixItems.AddAsync(new MixItem
|
||||
database.Mixes.RemoveRange(previousMixes);
|
||||
}
|
||||
|
||||
for (var position = 0; position < drafts.Count; position++)
|
||||
{
|
||||
var draft = drafts[position];
|
||||
var mix = new Mix
|
||||
{
|
||||
MixId = mix.Id,
|
||||
Position = itemPosition,
|
||||
TrackId = draft.TrackIds[itemPosition]
|
||||
}, cancellationToken);
|
||||
UserId = userId,
|
||||
Kind = draft.Kind,
|
||||
Position = position
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,27 +5,28 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Mixes.Responses;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
public record GetMixByIdQuery(long UserId, Guid MixId)
|
||||
: IQuery<ErrorOr<MixApplicationResponse>>;
|
||||
|
||||
public class GetMixByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
|
||||
namespace Musify.Application.Mixes
|
||||
{
|
||||
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
|
||||
public record GetMixByIdQuery(long UserId, Guid MixId)
|
||||
: IQuery<ErrorOr<MixApplicationResponse>>;
|
||||
|
||||
public class GetMixByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
|
||||
{
|
||||
var mix = await database.Mixes
|
||||
.AsNoTracking()
|
||||
.Include(mix => mix.Items)
|
||||
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
||||
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var mix = await database.Mixes
|
||||
.AsNoTracking()
|
||||
.Include(mix => mix.Items)
|
||||
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
||||
|
||||
if (mix == null)
|
||||
return AppErrors.NotFound("Mix", request.MixId);
|
||||
if (mix == null)
|
||||
return AppErrors.NotFound("Mix", request.MixId);
|
||||
|
||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
||||
|
||||
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
||||
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,48 +3,49 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Mixes.Responses;
|
||||
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
public record GetMixesByUserIdQuery(long UserId)
|
||||
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
||||
|
||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
|
||||
namespace Musify.Application.Mixes
|
||||
{
|
||||
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||
public record GetMixesByUserIdQuery(long UserId)
|
||||
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
||||
|
||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
|
||||
{
|
||||
var mixes = await database.Mixes
|
||||
.AsNoTracking()
|
||||
.Where(mix => mix.UserId == request.UserId)
|
||||
.Include(mix => mix.Items)
|
||||
.OrderBy(mix => mix.Position)
|
||||
.ThenBy(mix => mix.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var orderedItemsByMix = mixes.ToDictionary(
|
||||
mix => mix.Id,
|
||||
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
||||
|
||||
var coverItems = orderedItemsByMix.Values
|
||||
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
||||
.ToList();
|
||||
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||
.GroupBy(response => response.TrackId)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||
foreach (var mix in mixes)
|
||||
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var orderedItems = orderedItemsByMix[mix.Id];
|
||||
var covers = orderedItems
|
||||
.Take(MixApplicationResponse.CoverItemCount)
|
||||
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||
.Select(item => coverResponses[item.TrackId])
|
||||
var mixes = await database.Mixes
|
||||
.AsNoTracking()
|
||||
.Where(mix => mix.UserId == request.UserId)
|
||||
.Include(mix => mix.Items)
|
||||
.OrderBy(mix => mix.Position)
|
||||
.ThenBy(mix => mix.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var orderedItemsByMix = mixes.ToDictionary(
|
||||
mix => mix.Id,
|
||||
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
||||
|
||||
var coverItems = orderedItemsByMix.Values
|
||||
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
||||
.ToList();
|
||||
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||
.GroupBy(response => response.TrackId)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||
foreach (var mix in mixes)
|
||||
{
|
||||
var orderedItems = orderedItemsByMix[mix.Id];
|
||||
var covers = orderedItems
|
||||
.Take(MixApplicationResponse.CoverItemCount)
|
||||
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||
.Select(item => coverResponses[item.TrackId])
|
||||
.ToList();
|
||||
|
||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,37 +4,38 @@ using Musify.Application.Mixes.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
|
||||
internal static class MixItemMapper
|
||||
namespace Musify.Application.Mixes
|
||||
{
|
||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||
IDatabase database,
|
||||
IReadOnlyList<MixItem> orderedItems,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
internal static class MixItemMapper
|
||||
{
|
||||
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||
IDatabase database,
|
||||
IReadOnlyList<MixItem> orderedItems,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Include(track => track.Owner)
|
||||
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Include(track => track.Owner)
|
||||
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||
|
||||
return orderedItems
|
||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
||||
.Select(item =>
|
||||
{
|
||||
var entry = tracksById[item.TrackId];
|
||||
return new MixItemApplicationResponse(
|
||||
item.TrackId,
|
||||
entry.Track.Title,
|
||||
entry.Track.Owner.Name,
|
||||
entry.Track.DurationSeconds,
|
||||
entry.ListensCount);
|
||||
})
|
||||
.ToList();
|
||||
return orderedItems
|
||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
||||
.Select(item =>
|
||||
{
|
||||
var entry = tracksById[item.TrackId];
|
||||
return new MixItemApplicationResponse(
|
||||
item.TrackId,
|
||||
entry.Track.Title,
|
||||
entry.Track.Owner.Name,
|
||||
entry.Track.DurationSeconds,
|
||||
entry.ListensCount);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Mixes.Responses;
|
||||
|
||||
public record MixApplicationResponse(
|
||||
Guid Id,
|
||||
MixKind Kind,
|
||||
int ItemCount,
|
||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||
namespace Musify.Application.Mixes.Responses
|
||||
{
|
||||
public const int CoverItemCount = 4;
|
||||
public record MixApplicationResponse(
|
||||
Guid Id,
|
||||
MixKind Kind,
|
||||
int ItemCount,
|
||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||
{
|
||||
public const int CoverItemCount = 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace Musify.Application.Mixes.Responses;
|
||||
|
||||
public record MixItemApplicationResponse(
|
||||
Guid TrackId,
|
||||
string Title,
|
||||
string? Artist,
|
||||
double DurationSeconds,
|
||||
int ListensCount);
|
||||
namespace Musify.Application.Mixes.Responses
|
||||
{
|
||||
public record MixItemApplicationResponse(
|
||||
Guid TrackId,
|
||||
string Title,
|
||||
string? Artist,
|
||||
double DurationSeconds,
|
||||
int ListensCount);
|
||||
}
|
||||
|
||||
@@ -7,69 +7,70 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Pictures;
|
||||
|
||||
public enum CoverOwner
|
||||
namespace Musify.Application.Pictures
|
||||
{
|
||||
Track,
|
||||
Album,
|
||||
PlayList
|
||||
}
|
||||
|
||||
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
|
||||
: IQuery<ErrorOr<CoverLocation>>;
|
||||
|
||||
public record CoverLocation(string Bucket, string Key, string ContentType);
|
||||
|
||||
public class GetCoverQueryHandler(
|
||||
IDatabase database,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration,
|
||||
AlbumConfiguration albumConfiguration,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
|
||||
{
|
||||
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
|
||||
|
||||
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
|
||||
public enum CoverOwner
|
||||
{
|
||||
var (source, routes) = request.Owner switch
|
||||
Track,
|
||||
Album,
|
||||
PlayList
|
||||
}
|
||||
|
||||
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
|
||||
: IQuery<ErrorOr<CoverLocation>>;
|
||||
|
||||
public record CoverLocation(string Bucket, string Key, string ContentType);
|
||||
|
||||
public class GetCoverQueryHandler(
|
||||
IDatabase database,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration,
|
||||
AlbumConfiguration albumConfiguration,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
|
||||
{
|
||||
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
|
||||
|
||||
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
|
||||
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
|
||||
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
||||
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
||||
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
||||
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
|
||||
_ => (await database.PlayLists.AsNoTracking()
|
||||
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
|
||||
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
|
||||
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
|
||||
};
|
||||
var (source, routes) = request.Owner switch
|
||||
{
|
||||
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
|
||||
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
|
||||
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
||||
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
||||
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
||||
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
|
||||
_ => (await database.PlayLists.AsNoTracking()
|
||||
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
|
||||
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
|
||||
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
|
||||
};
|
||||
|
||||
var entity = request.Owner.ToString();
|
||||
if (source == null)
|
||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||
var entity = request.Owner.ToString();
|
||||
if (source == null)
|
||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||
|
||||
var resizedName = request.Size switch
|
||||
{
|
||||
PictureSize.Small => source.Small,
|
||||
PictureSize.Large => source.Large,
|
||||
_ => source.Medium
|
||||
};
|
||||
var resizedName = request.Size switch
|
||||
{
|
||||
PictureSize.Small => source.Small,
|
||||
PictureSize.Large => source.Large,
|
||||
_ => source.Medium
|
||||
};
|
||||
|
||||
// The resized files only exist once processing has finished; until then serve the original.
|
||||
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||
: !string.IsNullOrEmpty(source.Original)
|
||||
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
|
||||
: (null, null);
|
||||
// The resized files only exist once processing has finished; until then serve the original.
|
||||
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||
: !string.IsNullOrEmpty(source.Original)
|
||||
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
|
||||
: (null, null);
|
||||
|
||||
if (name == null || key == null)
|
||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||
if (name == null || key == null)
|
||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||
|
||||
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
||||
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,32 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Pictures;
|
||||
|
||||
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
||||
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||
|
||||
public static class PictureSourceChange
|
||||
namespace Musify.Application.Pictures
|
||||
{
|
||||
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
Guid intentId,
|
||||
long userId,
|
||||
UploadIntentPurpose purpose,
|
||||
IPictureOwnerConfiguration owner,
|
||||
CancellationToken cancellationToken)
|
||||
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
||||
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||
|
||||
public static class PictureSourceChange
|
||||
{
|
||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
|
||||
if (validation.IsError)
|
||||
return validation.Errors;
|
||||
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
Guid intentId,
|
||||
long userId,
|
||||
UploadIntentPurpose purpose,
|
||||
IPictureOwnerConfiguration owner,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
|
||||
if (validation.IsError)
|
||||
return validation.Errors;
|
||||
|
||||
var intent = validation.Value;
|
||||
var intent = validation.Value;
|
||||
|
||||
return new PreparedPicture(
|
||||
intent,
|
||||
EntityPictures.Pending(intent.ObjectName),
|
||||
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
|
||||
owner.PicturesSizes.ToImageSizes(owner.Routes));
|
||||
return new PreparedPicture(
|
||||
intent,
|
||||
EntityPictures.Pending(intent.ObjectName),
|
||||
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
|
||||
owner.PicturesSizes.ToImageSizes(owner.Routes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
namespace Musify.Application.Pictures.Responses;
|
||||
|
||||
public record PictureUploadResponse(
|
||||
Guid IntentId,
|
||||
string Bucket,
|
||||
string Key,
|
||||
string PictureName,
|
||||
string ContentType,
|
||||
int ExpiresInSeconds,
|
||||
string UploadUrl);
|
||||
namespace Musify.Application.Pictures.Responses
|
||||
{
|
||||
public record PictureUploadResponse(
|
||||
Guid IntentId,
|
||||
string Bucket,
|
||||
string Key,
|
||||
string PictureName,
|
||||
string ContentType,
|
||||
int ExpiresInSeconds,
|
||||
string UploadUrl);
|
||||
}
|
||||
|
||||
@@ -7,67 +7,68 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists;
|
||||
|
||||
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTracksToPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTracksToPlayListCommandHandler> logger)
|
||||
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
||||
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTracksToPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTracksToPlayListCommandHandler> logger)
|
||||
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
||||
{
|
||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (playList.IsError)
|
||||
return playList.Errors;
|
||||
|
||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||
|
||||
var existingIds = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => track.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
||||
if (missing != Guid.Empty)
|
||||
return AppErrors.NotFound("Track", missing);
|
||||
|
||||
var alreadyInPlayList = await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
||||
.Select(link => link.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Playlist positions are internal indexes, so they start at 0.
|
||||
var nextPosition = (await database.PlayListHasTracks
|
||||
.Where(link => link.PlayListId == request.PlayListId)
|
||||
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
||||
|
||||
foreach (var trackId in toAdd)
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (playList.IsError)
|
||||
return playList.Errors;
|
||||
|
||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||
|
||||
var existingIds = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => track.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
||||
if (missing != Guid.Empty)
|
||||
return AppErrors.NotFound("Track", missing);
|
||||
|
||||
var alreadyInPlayList = await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
||||
.Select(link => link.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Playlist positions are internal indexes, so they start at 0.
|
||||
var nextPosition = (await database.PlayListHasTracks
|
||||
.Where(link => link.PlayListId == request.PlayListId)
|
||||
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
||||
|
||||
foreach (var trackId in toAdd)
|
||||
{
|
||||
PlayListId = request.PlayListId,
|
||||
TrackId = trackId,
|
||||
Position = nextPosition++
|
||||
}, cancellationToken);
|
||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||
{
|
||||
PlayListId = request.PlayListId,
|
||||
TrackId = trackId,
|
||||
Position = nextPosition++
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,67 +12,68 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||
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,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", request.UserId);
|
||||
|
||||
PreparedPicture? picture = null;
|
||||
if (request.PictureIntentId is { } intentId)
|
||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", request.UserId);
|
||||
|
||||
picture = prepared.Value;
|
||||
PreparedPicture? picture = null;
|
||||
if (request.PictureIntentId is { } intentId)
|
||||
{
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
|
||||
picture = prepared.Value;
|
||||
}
|
||||
|
||||
var name = request.Name.Trim();
|
||||
var playList = new PlayList
|
||||
{
|
||||
OwnerUserId = request.UserId,
|
||||
Name = name,
|
||||
NormalizedName = TextNormalizer.Normalize(name),
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Pictures = picture?.Pictures,
|
||||
Visibility = request.Visibility
|
||||
};
|
||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||
|
||||
if (picture != null)
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new CreatePlayListResourcesEvent(
|
||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
||||
|
||||
return PlayListApplicationResponse.FromEntity(playList);
|
||||
}
|
||||
|
||||
var name = request.Name.Trim();
|
||||
var playList = new PlayList
|
||||
{
|
||||
OwnerUserId = request.UserId,
|
||||
Name = name,
|
||||
NormalizedName = TextNormalizer.Normalize(name),
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Pictures = picture?.Pictures,
|
||||
Visibility = request.Visibility
|
||||
};
|
||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||
|
||||
if (picture != null)
|
||||
{
|
||||
await eventBus.PublishAsync(
|
||||
new CreatePlayListResourcesEvent(
|
||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
||||
|
||||
return PlayListApplicationResponse.FromEntity(playList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,31 +6,32 @@ using Musify.Application.Events;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
||||
public record DeletePlayListCommand(long UserId, Guid PlayListId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class DeletePlayListCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeletePlayListCommandHandler> logger)
|
||||
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
|
||||
{
|
||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var playList = found.Value;
|
||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
var playList = found.Value;
|
||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
|
||||
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
|
||||
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
|
||||
|
||||
return Result.Success;
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,27 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.PlayLists;
|
||||
|
||||
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
|
||||
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
||||
|
||||
public class GetPlayListByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.VisibleTo(request.RequestingUserId)
|
||||
.Where(p => p.Id == request.Id)
|
||||
.SelectResponse()
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
|
||||
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
||||
|
||||
return playList == null
|
||||
? AppErrors.NotFound("PlayList", request.Id)
|
||||
: playList;
|
||||
public class GetPlayListByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.VisibleTo(request.RequestingUserId)
|
||||
.Where(p => p.Id == request.Id)
|
||||
.SelectResponse()
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return playList == null
|
||||
? AppErrors.NotFound("PlayList", request.Id)
|
||||
: playList;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,35 +7,36 @@ using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists;
|
||||
|
||||
public record GetPlayListTracksQuery(
|
||||
Guid PlayListId,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
long? ViewerId = null)
|
||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
|
||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playListVisible = await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.VisibleTo(request.ViewerId)
|
||||
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
||||
if (!playListVisible)
|
||||
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||
public record GetPlayListTracksQuery(
|
||||
Guid PlayListId,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
long? ViewerId = null)
|
||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
|
||||
return await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.OrderBy(playListTrack => playListTrack.Position)
|
||||
.ThenBy(playListTrack => playListTrack.Id)
|
||||
.Select(playListTrack => playListTrack.Track)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playListVisible = await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.VisibleTo(request.ViewerId)
|
||||
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
||||
if (!playListVisible)
|
||||
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||
|
||||
return await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.OrderBy(playListTrack => playListTrack.Position)
|
||||
.ThenBy(playListTrack => playListTrack.Id)
|
||||
.Select(playListTrack => playListTrack.Track)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,37 +4,38 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.PlayLists;
|
||||
|
||||
public record GetPlayListsByUserIdQuery(
|
||||
long UserId,
|
||||
string? Name,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
long? ViewerId = null)
|
||||
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
||||
|
||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
||||
public record GetPlayListsByUserIdQuery(
|
||||
long UserId,
|
||||
string? Name,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
long? ViewerId = null)
|
||||
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
||||
|
||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
|
||||
{
|
||||
var playListsQuery = database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.VisibleTo(request.ViewerId)
|
||||
.Where(playList => playList.OwnerUserId == request.UserId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
||||
}
|
||||
var playListsQuery = database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.VisibleTo(request.ViewerId)
|
||||
.Where(playList => playList.OwnerUserId == request.UserId);
|
||||
|
||||
return await playListsQuery
|
||||
.OrderBy(playList => playList.CreatedAt)
|
||||
.ThenBy(playList => playList.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
||||
}
|
||||
|
||||
return await playListsQuery
|
||||
.OrderBy(playList => playList.CreatedAt)
|
||||
.ThenBy(playList => playList.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,41 +5,42 @@ using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
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>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class RemoveTrackFromPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
|
||||
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
|
||||
{
|
||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (playList.IsError)
|
||||
return playList.Errors;
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (playList.IsError)
|
||||
return playList.Errors;
|
||||
|
||||
var playListTracks = await database.PlayListHasTracks
|
||||
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||
.OrderBy(plt => plt.Position)
|
||||
.ToListAsync(cancellationToken);
|
||||
var playListTracks = await database.PlayListHasTracks
|
||||
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||
.OrderBy(plt => plt.Position)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
||||
if (link == null)
|
||||
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
||||
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
||||
if (link == null)
|
||||
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
||||
|
||||
database.PlayListHasTracks.Remove(link);
|
||||
database.PlayListHasTracks.Remove(link);
|
||||
|
||||
var position = 0;
|
||||
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
||||
remaining.Position = position++;
|
||||
var position = 0;
|
||||
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
||||
remaining.Position = position++;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||
|
||||
return Result.Success;
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists;
|
||||
|
||||
public record RequestPlayListPictureUploadCommand(
|
||||
long UserId,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null)
|
||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||
|
||||
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
|
||||
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||
await issuer.IssuePictureAsync(
|
||||
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
|
||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||
public record RequestPlayListPictureUploadCommand(
|
||||
long UserId,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes = null)
|
||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||
|
||||
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
|
||||
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||
await issuer.IssuePictureAsync(
|
||||
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
|
||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-37
@@ -2,44 +2,45 @@ using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
|
||||
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,
|
||||
long OwnerUserId,
|
||||
int TrackCount,
|
||||
double DurationSeconds)
|
||||
namespace Musify.Application.PlayLists.Responses
|
||||
{
|
||||
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
|
||||
|
||||
public static PlayListApplicationResponse FromEntity(
|
||||
PlayList playList,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null,
|
||||
int trackCount = 0,
|
||||
double durationSeconds = 0)
|
||||
public record PlayListApplicationResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
string? LargeImageKeyName,
|
||||
PlayListVisibility Visibility,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds,
|
||||
long OwnerUserId,
|
||||
int TrackCount,
|
||||
double DurationSeconds)
|
||||
{
|
||||
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 ?? [],
|
||||
playList.OwnerUserId,
|
||||
trackCount,
|
||||
durationSeconds);
|
||||
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
|
||||
|
||||
public static PlayListApplicationResponse FromEntity(
|
||||
PlayList playList,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null,
|
||||
int trackCount = 0,
|
||||
double durationSeconds = 0)
|
||||
{
|
||||
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 ?? [],
|
||||
playList.OwnerUserId,
|
||||
trackCount,
|
||||
durationSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists.Responses;
|
||||
|
||||
public static class PlayListProjections
|
||||
namespace Musify.Application.PlayLists.Responses
|
||||
{
|
||||
public const int CoverTrackCount = 4;
|
||||
public static class PlayListProjections
|
||||
{
|
||||
public const int CoverTrackCount = 4;
|
||||
|
||||
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
|
||||
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
|
||||
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
|
||||
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
|
||||
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
|
||||
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
|
||||
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
|
||||
|
||||
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
|
||||
query.Select(playList => new PlayListApplicationResponse(
|
||||
playList.Id,
|
||||
playList.Name,
|
||||
playList.Description,
|
||||
playList.Pictures!.SmallName,
|
||||
playList.Pictures!.MediumName,
|
||||
playList.Pictures!.LargeName,
|
||||
playList.Visibility,
|
||||
playList.CreatedAt,
|
||||
playList.UpdatedAt,
|
||||
playList.PlayListTracks
|
||||
.OrderBy(playListTrack => playListTrack.Position)
|
||||
.Take(CoverTrackCount)
|
||||
.Select(playListTrack => playListTrack.TrackId)
|
||||
.ToList(),
|
||||
playList.OwnerUserId,
|
||||
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
|
||||
playList.PlayListTracks
|
||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
||||
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
|
||||
query.Select(playList => new PlayListApplicationResponse(
|
||||
playList.Id,
|
||||
playList.Name,
|
||||
playList.Description,
|
||||
playList.Pictures!.SmallName,
|
||||
playList.Pictures!.MediumName,
|
||||
playList.Pictures!.LargeName,
|
||||
playList.Visibility,
|
||||
playList.CreatedAt,
|
||||
playList.UpdatedAt,
|
||||
playList.PlayListTracks
|
||||
.OrderBy(playListTrack => playListTrack.Position)
|
||||
.Take(CoverTrackCount)
|
||||
.Select(playListTrack => playListTrack.TrackId)
|
||||
.ToList(),
|
||||
playList.OwnerUserId,
|
||||
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
|
||||
playList.PlayListTracks
|
||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,70 +11,71 @@ using Musify.Application.Services;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||
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,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||
{
|
||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var playList = found.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
playList.Name = request.NewName.Trim();
|
||||
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var playList = found.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
||||
{
|
||||
playList.Name = request.NewName.Trim();
|
||||
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||
}
|
||||
|
||||
// null means "unchanged"; a blank description clears it.
|
||||
if (request.NewDescription != null)
|
||||
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||
|
||||
if (request.NewVisibility is { } visibility)
|
||||
playList.Visibility = visibility;
|
||||
|
||||
if (request.NewPictureIntentId is { } intentId)
|
||||
{
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
|
||||
var picture = prepared.Value;
|
||||
playList.Pictures = picture.Pictures;
|
||||
|
||||
await eventBus.PublishAsync(
|
||||
new UpdatePlayListPictureSourceEvent(
|
||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
|
||||
|
||||
return await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playList.Id)
|
||||
.SelectResponse()
|
||||
.SingleAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// null means "unchanged"; a blank description clears it.
|
||||
if (request.NewDescription != null)
|
||||
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||
|
||||
if (request.NewVisibility is { } visibility)
|
||||
playList.Visibility = visibility;
|
||||
|
||||
if (request.NewPictureIntentId is { } intentId)
|
||||
{
|
||||
var prepared = await PictureSourceChange.PrepareAsync(
|
||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
return prepared.Errors;
|
||||
|
||||
var picture = prepared.Value;
|
||||
playList.Pictures = picture.Pictures;
|
||||
|
||||
await eventBus.PublishAsync(
|
||||
new UpdatePlayListPictureSourceEvent(
|
||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
|
||||
|
||||
return await database.PlayLists
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playList.Id)
|
||||
.SelectResponse()
|
||||
.SingleAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,34 +2,35 @@ using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Services;
|
||||
|
||||
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
||||
public sealed class TrackStreamIssuer(
|
||||
IStreamTicketService ticketService,
|
||||
TrackConfiguration trackConfiguration,
|
||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
||||
PlaybackConfiguration playbackConfiguration)
|
||||
namespace Musify.Application.Services
|
||||
{
|
||||
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
|
||||
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
||||
public sealed class TrackStreamIssuer(
|
||||
IStreamTicketService ticketService,
|
||||
TrackConfiguration trackConfiguration,
|
||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
||||
PlaybackConfiguration playbackConfiguration)
|
||||
{
|
||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
||||
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
|
||||
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
|
||||
{
|
||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
||||
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
|
||||
|
||||
var manifestUrl = string.Join('/',
|
||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
||||
"media",
|
||||
folderPath,
|
||||
streamGatewayConfiguration.AudioFileName);
|
||||
var manifestUrl = string.Join('/',
|
||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
||||
"media",
|
||||
folderPath,
|
||||
streamGatewayConfiguration.AudioFileName);
|
||||
|
||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
||||
}
|
||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
||||
}
|
||||
|
||||
private long? AnonymousFragmentBytes(long? userId)
|
||||
{
|
||||
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
||||
return null;
|
||||
private long? AnonymousFragmentBytes(long? userId)
|
||||
{
|
||||
if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0)
|
||||
return null;
|
||||
|
||||
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
||||
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,126 +8,127 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Services;
|
||||
|
||||
public sealed record UploadRequest(
|
||||
UploadIntentPurpose Purpose,
|
||||
PictureRoutes Routes,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes);
|
||||
|
||||
public sealed record IssuedUpload(
|
||||
Guid IntentId,
|
||||
string Key,
|
||||
string ObjectName,
|
||||
string ContentType,
|
||||
string UploadUrl);
|
||||
|
||||
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
|
||||
public sealed class UploadIntentIssuer(
|
||||
IDatabase database,
|
||||
IStorageService storageService,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration,
|
||||
ILogger<UploadIntentIssuer> logger)
|
||||
namespace Musify.Application.Services
|
||||
{
|
||||
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
|
||||
long userId,
|
||||
IReadOnlyList<UploadRequest> requests,
|
||||
CancellationToken cancellationToken)
|
||||
public sealed record UploadRequest(
|
||||
UploadIntentPurpose Purpose,
|
||||
PictureRoutes Routes,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes);
|
||||
|
||||
public sealed record IssuedUpload(
|
||||
Guid IntentId,
|
||||
string Key,
|
||||
string ObjectName,
|
||||
string ContentType,
|
||||
string UploadUrl);
|
||||
|
||||
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
|
||||
public sealed class UploadIntentIssuer(
|
||||
IDatabase database,
|
||||
IStorageService storageService,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration,
|
||||
ILogger<UploadIntentIssuer> logger)
|
||||
{
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == userId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", userId);
|
||||
|
||||
var planned = requests
|
||||
.Select(request =>
|
||||
{
|
||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
||||
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
|
||||
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
|
||||
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await using var transaction = await database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
var quota = await uploadIntentValidator.CheckQuotaAsync(
|
||||
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
|
||||
if (quota.IsError)
|
||||
return quota.Errors;
|
||||
|
||||
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
var expiresAt = DateTime.UtcNow + expiresIn;
|
||||
var issued = new List<IssuedUpload>(planned.Count);
|
||||
|
||||
foreach (var item in planned)
|
||||
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
|
||||
long userId,
|
||||
IReadOnlyList<UploadRequest> requests,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken);
|
||||
var userExists = await database.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == userId, cancellationToken);
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", userId);
|
||||
|
||||
var intent = new UploadIntent
|
||||
var planned = requests
|
||||
.Select(request =>
|
||||
{
|
||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
||||
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
|
||||
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
|
||||
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await using var transaction = await database.BeginTransactionAsync(
|
||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||
|
||||
var quota = await uploadIntentValidator.CheckQuotaAsync(
|
||||
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
|
||||
if (quota.IsError)
|
||||
return quota.Errors;
|
||||
|
||||
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
var expiresAt = DateTime.UtcNow + expiresIn;
|
||||
var issued = new List<IssuedUpload>(planned.Count);
|
||||
|
||||
foreach (var item in planned)
|
||||
{
|
||||
UserId = userId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = item.Key,
|
||||
ObjectName = item.ObjectName,
|
||||
ContentType = item.Request.ContentType,
|
||||
ExpectedSizeBytes = item.Size,
|
||||
Purpose = item.Request.Purpose,
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken);
|
||||
|
||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
||||
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
|
||||
var intent = new UploadIntent
|
||||
{
|
||||
UserId = userId,
|
||||
Bucket = storageConfiguration.Bucket,
|
||||
Key = item.Key,
|
||||
ObjectName = item.ObjectName,
|
||||
ContentType = item.Request.ContentType,
|
||||
ExpectedSizeBytes = item.Size,
|
||||
Purpose = item.Request.Purpose,
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
|
||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
||||
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
|
||||
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
|
||||
|
||||
return issued;
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var issued = await IssueAsync(userId, [request], cancellationToken);
|
||||
return issued.IsError ? issued.Errors : issued.Value[0];
|
||||
}
|
||||
|
||||
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
|
||||
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
|
||||
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
|
||||
long userId,
|
||||
UploadIntentPurpose purpose,
|
||||
IPictureOwnerConfiguration owner,
|
||||
string fileType,
|
||||
string contentType,
|
||||
long? expectedSizeBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var issued = await IssueOneAsync(
|
||||
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
|
||||
if (issued.IsError)
|
||||
return issued.Errors;
|
||||
|
||||
return issued;
|
||||
return new PictureUploadResponse(
|
||||
issued.Value.IntentId,
|
||||
storageConfiguration.Bucket,
|
||||
issued.Value.Key,
|
||||
issued.Value.ObjectName,
|
||||
issued.Value.ContentType,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||
issued.Value.UploadUrl);
|
||||
}
|
||||
|
||||
private long DefaultSize(UploadIntentPurpose purpose) =>
|
||||
purpose == UploadIntentPurpose.TrackAudio
|
||||
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
|
||||
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||
}
|
||||
|
||||
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var issued = await IssueAsync(userId, [request], cancellationToken);
|
||||
return issued.IsError ? issued.Errors : issued.Value[0];
|
||||
}
|
||||
|
||||
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
|
||||
long userId,
|
||||
UploadIntentPurpose purpose,
|
||||
IPictureOwnerConfiguration owner,
|
||||
string fileType,
|
||||
string contentType,
|
||||
long? expectedSizeBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var issued = await IssueOneAsync(
|
||||
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
|
||||
if (issued.IsError)
|
||||
return issued.Errors;
|
||||
|
||||
return new PictureUploadResponse(
|
||||
issued.Value.IntentId,
|
||||
storageConfiguration.Bucket,
|
||||
issued.Value.Key,
|
||||
issued.Value.ObjectName,
|
||||
issued.Value.ContentType,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||
issued.Value.UploadUrl);
|
||||
}
|
||||
|
||||
private long DefaultSize(UploadIntentPurpose purpose) =>
|
||||
purpose == UploadIntentPurpose.TrackAudio
|
||||
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
|
||||
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||
}
|
||||
|
||||
@@ -6,72 +6,73 @@ using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Services;
|
||||
|
||||
public sealed class UploadIntentValidator(
|
||||
IDatabase database,
|
||||
IStorageService storageService,
|
||||
UploadIntentConfiguration config)
|
||||
namespace Musify.Application.Services
|
||||
{
|
||||
public async Task<ErrorOr<Success>> CheckQuotaAsync(
|
||||
long userId,
|
||||
long requiredBytes,
|
||||
int requiredIntentCount,
|
||||
CancellationToken cancellationToken)
|
||||
public sealed class UploadIntentValidator(
|
||||
IDatabase database,
|
||||
IStorageService storageService,
|
||||
UploadIntentConfiguration config)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var pictureDefault = config.DefaultExpectedPictureSizeBytes;
|
||||
var audioDefault = config.DefaultExpectedAudioSizeBytes;
|
||||
public async Task<ErrorOr<Success>> CheckQuotaAsync(
|
||||
long userId,
|
||||
long requiredBytes,
|
||||
int requiredIntentCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var pictureDefault = config.DefaultExpectedPictureSizeBytes;
|
||||
var audioDefault = config.DefaultExpectedAudioSizeBytes;
|
||||
|
||||
var active = await database.UploadIntents
|
||||
.AsNoTracking()
|
||||
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
|
||||
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
var active = await database.UploadIntents
|
||||
.AsNoTracking()
|
||||
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new
|
||||
{
|
||||
Count = group.Count(),
|
||||
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
|
||||
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
||||
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
|
||||
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
||||
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
|
||||
|
||||
if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
||||
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
||||
if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
||||
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
||||
Guid intentId,
|
||||
long userId,
|
||||
UploadIntentPurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var intent = await database.UploadIntents
|
||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
||||
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
||||
Guid intentId,
|
||||
long userId,
|
||||
UploadIntentPurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var intent = await database.UploadIntents
|
||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
||||
|
||||
if (intent == null || intent.UserId != userId)
|
||||
return AppErrors.NotFound("UploadIntent", intentId);
|
||||
if (intent == null || intent.UserId != userId)
|
||||
return AppErrors.NotFound("UploadIntent", intentId);
|
||||
|
||||
if (intent.Purpose != purpose)
|
||||
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
||||
if (intent.Purpose != purpose)
|
||||
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
||||
|
||||
if (intent.IsConsumed)
|
||||
return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed.");
|
||||
if (intent.IsConsumed)
|
||||
return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed.");
|
||||
|
||||
if (intent.IsExpiredAt(DateTime.UtcNow))
|
||||
return Error.Validation(description: "Upload intent has expired.");
|
||||
if (intent.IsExpiredAt(DateTime.UtcNow))
|
||||
return Error.Validation(description: "Upload intent has expired.");
|
||||
|
||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
||||
if (metadata == null)
|
||||
return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first.");
|
||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
||||
if (metadata == null)
|
||||
return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first.");
|
||||
|
||||
if (metadata.ContentLength > config.MaxUploadBytes)
|
||||
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
||||
if (metadata.ContentLength > config.MaxUploadBytes)
|
||||
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
||||
|
||||
return intent;
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using ErrorOr;
|
||||
|
||||
namespace Musify.Application.Shared;
|
||||
|
||||
public static class AppErrors
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
public static Error NotFound(string entity, object id) =>
|
||||
Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found.");
|
||||
public static class AppErrors
|
||||
{
|
||||
public static Error NotFound(string entity, object id) =>
|
||||
Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found.");
|
||||
|
||||
public static Error Forbidden(string entity, object id) =>
|
||||
Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}.");
|
||||
public static Error Forbidden(string entity, object id) =>
|
||||
Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}.");
|
||||
|
||||
public static Error Conflict(string code, string description) =>
|
||||
Error.Conflict(code, description);
|
||||
public static Error Conflict(string code, string description) =>
|
||||
Error.Conflict(code, description);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
namespace Musify.Application.Shared;
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
public record ImageSize(
|
||||
string SavePath,
|
||||
int Width,
|
||||
int Height);
|
||||
|
||||
public record ImageSize(
|
||||
string SavePath,
|
||||
int Width,
|
||||
int Height);
|
||||
|
||||
public record ImageSizes(
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
public record ImageSizes(
|
||||
ImageSize Small,
|
||||
ImageSize Medium,
|
||||
ImageSize Large);
|
||||
}
|
||||
|
||||
@@ -3,26 +3,27 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Domain.Abstractions;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Shared;
|
||||
|
||||
public static class OwnershipExtensions
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
|
||||
/// or not active, Forbidden when someone else owns it.
|
||||
/// </summary>
|
||||
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
||||
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
||||
where T : class, IOwnedEntity, IHasLifeCycle
|
||||
public static class OwnershipExtensions
|
||||
{
|
||||
var entity = await set.SingleOrDefaultAsync(
|
||||
e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||
/// <summary>
|
||||
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
|
||||
/// or not active, Forbidden when someone else owns it.
|
||||
/// </summary>
|
||||
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
||||
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
||||
where T : class, IOwnedEntity, IHasLifeCycle
|
||||
{
|
||||
var entity = await set.SingleOrDefaultAsync(
|
||||
e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
return AppErrors.NotFound(typeof(T).Name, id);
|
||||
if (entity == null)
|
||||
return AppErrors.NotFound(typeof(T).Name, id);
|
||||
|
||||
return entity.OwnerUserId == userId
|
||||
? entity
|
||||
: AppErrors.Forbidden(typeof(T).Name, id);
|
||||
return entity.OwnerUserId == userId
|
||||
? entity
|
||||
: AppErrors.Forbidden(typeof(T).Name, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Musify.Application.Shared;
|
||||
|
||||
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
public const int MaxPageSize = 100;
|
||||
}
|
||||
|
||||
public record PaginatedResponse<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int TotalItemCount,
|
||||
bool HasNextPage,
|
||||
bool HasPreviousPage);
|
||||
|
||||
public static class PaginationExtensions
|
||||
{
|
||||
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
|
||||
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
||||
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
||||
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
||||
{
|
||||
var pageNumber = Math.Max(page.PageNumber, 1);
|
||||
var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize);
|
||||
public const int MaxPageSize = 100;
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
public record PaginatedResponse<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int TotalItemCount,
|
||||
bool HasNextPage,
|
||||
bool HasPreviousPage);
|
||||
|
||||
var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
public static class PaginationExtensions
|
||||
{
|
||||
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
|
||||
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
||||
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
||||
{
|
||||
var pageNumber = Math.Max(page.PageNumber, 1);
|
||||
var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize);
|
||||
|
||||
return new PaginatedResponse<T>(
|
||||
items,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
pageCount,
|
||||
totalCount,
|
||||
HasNextPage: pageNumber < pageCount,
|
||||
HasPreviousPage: pageNumber > 1 && pageCount > 0);
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
|
||||
return new PaginatedResponse<T>(
|
||||
items,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
pageCount,
|
||||
totalCount,
|
||||
HasNextPage: pageNumber < pageCount,
|
||||
HasPreviousPage: pageNumber > 1 && pageCount > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
namespace Musify.Application.Shared;
|
||||
|
||||
public static class StorageKey
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
public static string Combine(params string[] segments)
|
||||
public static class StorageKey
|
||||
{
|
||||
return string.Join('/', segments
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s.Trim().Trim('/', '\\')));
|
||||
public static string Combine(params string[] segments)
|
||||
{
|
||||
return string.Join('/', segments
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s.Trim().Trim('/', '\\')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace Musify.Application.Shared;
|
||||
|
||||
public static class TextNormalizer
|
||||
namespace Musify.Application.Shared
|
||||
{
|
||||
public static string Normalize(string value) => value.Trim().ToUpperInvariant();
|
||||
public static class TextNormalizer
|
||||
{
|
||||
public static string Normalize(string value) => value.Trim().ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,103 +11,104 @@ using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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,
|
||||
TrackConfiguration trackConfiguration)
|
||||
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
||||
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,
|
||||
TrackConfiguration trackConfiguration)
|
||||
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
|
||||
{
|
||||
var user = await database.Users
|
||||
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var distinctTags = request.Tags.Distinct().ToList();
|
||||
|
||||
if (request.PictureIntentId == request.AudioIntentId)
|
||||
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||
|
||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||
if (pictureValidation.IsError)
|
||||
return pictureValidation.Errors;
|
||||
|
||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, 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.Trim(),
|
||||
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
||||
OwnerUserId = user.Id,
|
||||
Owner = user,
|
||||
IsExplicit = request.IsExplicit,
|
||||
Pictures = new TrackPictures
|
||||
var user = await database.Users
|
||||
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
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
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
}
|
||||
};
|
||||
|
||||
trackEntity.Tags = distinctTags
|
||||
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
|
||||
.ToList();
|
||||
var distinctTags = request.Tags.Distinct().ToList();
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
if (request.PictureIntentId == request.AudioIntentId)
|
||||
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||
|
||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||
if (pictureValidation.IsError)
|
||||
return pictureValidation.Errors;
|
||||
|
||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, 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.Trim(),
|
||||
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
||||
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);
|
||||
|
||||
|
||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||
await eventBus.PublishAsync(
|
||||
new CreateTrackResourcesEvent(
|
||||
trackEntity.Id,
|
||||
pictureIntent.Id,
|
||||
audioIntent.Id,
|
||||
pictureIntent.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
audioIntent.Key,
|
||||
finalAudioKey,
|
||||
audioProcessedFolderKey,
|
||||
sizes),
|
||||
cancellationToken);
|
||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||
await eventBus.PublishAsync(
|
||||
new CreateTrackResourcesEvent(
|
||||
trackEntity.Id,
|
||||
pictureIntent.Id,
|
||||
audioIntent.Id,
|
||||
pictureIntent.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
audioIntent.Key,
|
||||
finalAudioKey,
|
||||
audioProcessedFolderKey,
|
||||
sizes),
|
||||
cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
||||
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,31 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record DeleteStaleUncountedListensCommand : ICommand<int>;
|
||||
|
||||
public class DeleteStaleUncountedListensCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
|
||||
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
|
||||
public record DeleteStaleUncountedListensCommand : ICommand<int>;
|
||||
|
||||
public class DeleteStaleUncountedListensCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
|
||||
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
|
||||
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
|
||||
|
||||
var stale = await database.ListeningHistories
|
||||
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
|
||||
.ToListAsync(cancellationToken);
|
||||
var stale = await database.ListeningHistories
|
||||
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (stale.Count == 0)
|
||||
return 0;
|
||||
if (stale.Count == 0)
|
||||
return 0;
|
||||
|
||||
database.ListeningHistories.RemoveRange(stale);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
database.ListeningHistories.RemoveRange(stale);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
|
||||
return stale.Count;
|
||||
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
|
||||
return stale.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,36 +6,37 @@ using Musify.Application.Events;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
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>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||
public record DeleteTrackCommand(
|
||||
long UserId,
|
||||
Guid TrackId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class DeleteTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeleteTrackCommandHandler> logger)
|
||||
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
|
||||
{
|
||||
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
|
||||
var track = found.Value;
|
||||
if (track.Audio.IsInProgress)
|
||||
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
||||
var track = found.Value;
|
||||
if (track.Audio.IsInProgress)
|
||||
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
||||
|
||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
|
||||
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
|
||||
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
|
||||
|
||||
return Result.Success;
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,24 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
|
||||
|
||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<TrackApplicationResponse?> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
|
||||
|
||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
|
||||
{
|
||||
return await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Track.Owner)
|
||||
.Include(t => t.Track.Tags)
|
||||
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.OrderByDescending(t => t.ListenedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
public async ValueTask<TrackApplicationResponse?> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Track.Owner)
|
||||
.Include(t => t.Track.Tags)
|
||||
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.OrderByDescending(t => t.ListenedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,37 +3,38 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
||||
|
||||
public class GetListeningHistoryQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public const int ListSize = 15;
|
||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
||||
|
||||
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||
public class GetListeningHistoryQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
||||
{
|
||||
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))
|
||||
.ThenBy(g => g.Key)
|
||||
.Select(g => g.Key)
|
||||
.Take(ListSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
public const int ListSize = 15;
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
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))
|
||||
.ThenBy(g => g.Key)
|
||||
.Select(g => g.Key)
|
||||
.Take(ListSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return recentTrackIds
|
||||
.Where(id => tracksById.ContainsKey(id))
|
||||
.Select(id => tracksById[id])
|
||||
.ToList();
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
return recentTrackIds
|
||||
.Where(id => tracksById.ContainsKey(id))
|
||||
.Select(id => tracksById[id])
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user