Add editorconfig and analyzers, format backend code
This commit is contained in:
@@ -7,146 +7,145 @@ using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Mixes
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
public record GenerateMixesForUserCommand(long UserId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class GenerateMixesForUserCommandHandler(
|
||||
IDatabase database,
|
||||
MixConfiguration mixConfiguration,
|
||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
|
||||
{
|
||||
public record GenerateMixesForUserCommand(long UserId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
|
||||
|
||||
public class GenerateMixesForUserCommandHandler(
|
||||
IDatabase database,
|
||||
MixConfiguration mixConfiguration,
|
||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
|
||||
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
|
||||
var userId = request.UserId;
|
||||
|
||||
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||
var libraryTrackIds = await database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(userTrack => userTrack.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (libraryTrackIds.Count == 0)
|
||||
{
|
||||
var userId = request.UserId;
|
||||
|
||||
var libraryTrackIds = await database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(userTrack => userTrack.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (libraryTrackIds.Count == 0)
|
||||
{
|
||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||
return new Success();
|
||||
}
|
||||
|
||||
var historyTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == userId && history.IsCounted)
|
||||
.Select(history => history.TrackId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var historySet = new HashSet<Guid>(historyTrackIds);
|
||||
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
||||
|
||||
var drafts = new List<MixDraft>();
|
||||
|
||||
var discovery = Take(Shuffle(unheardTrackIds));
|
||||
if (discovery.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(
|
||||
"Descubrimiento",
|
||||
"Canciones de tu biblioteca que todavía no has escuchado.",
|
||||
discovery));
|
||||
}
|
||||
|
||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||
if (daily.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(
|
||||
"Tu mezcla diaria",
|
||||
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
|
||||
daily));
|
||||
}
|
||||
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||
return new Success();
|
||||
}
|
||||
|
||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
|
||||
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
|
||||
}
|
||||
|
||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||
return new Success();
|
||||
}
|
||||
|
||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
||||
var historyTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == userId && history.IsCounted)
|
||||
.Select(history => history.TrackId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
||||
var historySet = new HashSet<Guid>(historyTrackIds);
|
||||
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
||||
|
||||
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
|
||||
var drafts = new List<MixDraft>();
|
||||
|
||||
var discovery = Take(Shuffle(unheardTrackIds));
|
||||
if (discovery.Count > 0)
|
||||
{
|
||||
var merged = new List<Guid>(first.Count + second.Count);
|
||||
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;
|
||||
drafts.Add(new MixDraft(
|
||||
"Descubrimiento",
|
||||
"Canciones de tu biblioteca que todavía no has escuchado.",
|
||||
discovery));
|
||||
}
|
||||
|
||||
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
|
||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||
if (daily.Count > 0)
|
||||
{
|
||||
var previousMixes = await database.Mixes
|
||||
.Where(mix => mix.UserId == userId)
|
||||
drafts.Add(new MixDraft(
|
||||
"Tu mezcla diaria",
|
||||
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
|
||||
daily));
|
||||
}
|
||||
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||
return new Success();
|
||||
}
|
||||
|
||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
|
||||
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
|
||||
}
|
||||
|
||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||
return new Success();
|
||||
}
|
||||
|
||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
||||
|
||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
||||
|
||||
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
|
||||
{
|
||||
var merged = new List<Guid>(first.Count + second.Count);
|
||||
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
||||
{
|
||||
if (index < first.Count)
|
||||
merged.Add(first[index]);
|
||||
if (index < second.Count)
|
||||
merged.Add(second[index]);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
|
||||
{
|
||||
var previousMixes = await database.Mixes
|
||||
.Where(mix => mix.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (previousMixes.Count > 0)
|
||||
{
|
||||
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
|
||||
var previousItems = await database.MixItems
|
||||
.Where(item => previousIds.Contains(item.MixId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (previousMixes.Count > 0)
|
||||
database.MixItems.RemoveRange(previousItems);
|
||||
database.Mixes.RemoveRange(previousMixes);
|
||||
}
|
||||
|
||||
for (var position = 0; position < drafts.Count; position++)
|
||||
{
|
||||
var draft = drafts[position];
|
||||
var mix = new Mix
|
||||
{
|
||||
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
|
||||
var previousItems = await database.MixItems
|
||||
.Where(item => previousIds.Contains(item.MixId))
|
||||
.ToListAsync(cancellationToken);
|
||||
UserId = userId,
|
||||
Title = draft.Title,
|
||||
Subtitle = draft.Subtitle,
|
||||
Position = position
|
||||
};
|
||||
|
||||
database.MixItems.RemoveRange(previousItems);
|
||||
database.Mixes.RemoveRange(previousMixes);
|
||||
}
|
||||
await database.Mixes.AddAsync(mix, cancellationToken);
|
||||
|
||||
for (var position = 0; position < drafts.Count; position++)
|
||||
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
||||
{
|
||||
var draft = drafts[position];
|
||||
var mix = new Mix
|
||||
await database.MixItems.AddAsync(new MixItem
|
||||
{
|
||||
UserId = userId,
|
||||
Title = draft.Title,
|
||||
Subtitle = draft.Subtitle,
|
||||
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);
|
||||
}
|
||||
MixId = mix.Id,
|
||||
Position = itemPosition,
|
||||
TrackId = draft.TrackIds[itemPosition]
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,27 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Mixes.Responses;
|
||||
|
||||
namespace Musify.Application.Mixes
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
public record GetMixByIdQuery(long UserId, Guid MixId)
|
||||
: IQuery<ErrorOr<MixApplicationResponse>>;
|
||||
|
||||
public class GetMixByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
|
||||
{
|
||||
public record GetMixByIdQuery(long UserId, Guid MixId)
|
||||
: IQuery<ErrorOr<MixApplicationResponse>>;
|
||||
|
||||
public class GetMixByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
|
||||
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken 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);
|
||||
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 Error.NotFound(description: $"Mix {request.MixId} was not found");
|
||||
if (mix == null)
|
||||
return Error.NotFound(description: $"Mix {request.MixId} was not found");
|
||||
|
||||
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.Title, mix.Subtitle, items.Count, items);
|
||||
}
|
||||
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,34 +4,33 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Mixes.Responses;
|
||||
|
||||
namespace Musify.Application.Mixes
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
public record GetMixesByUserIdQuery(long UserId)
|
||||
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
|
||||
|
||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
|
||||
{
|
||||
public record GetMixesByUserIdQuery(long UserId)
|
||||
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
|
||||
|
||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
|
||||
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||
var mixes = await database.Mixes
|
||||
.AsNoTracking()
|
||||
.Where(mix => mix.UserId == request.UserId)
|
||||
.Include(mix => mix.Items)
|
||||
.OrderBy(mix => mix.Position)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||
foreach (var mix in mixes)
|
||||
{
|
||||
var mixes = await database.Mixes
|
||||
.AsNoTracking()
|
||||
.Where(mix => mix.UserId == request.UserId)
|
||||
.Include(mix => mix.Items)
|
||||
.OrderBy(mix => mix.Position)
|
||||
.ToListAsync(cancellationToken);
|
||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var coverItems = await MixItemMapper.ToResponsesAsync(
|
||||
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
|
||||
|
||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||
foreach (var mix in mixes)
|
||||
{
|
||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var coverItems = await MixItemMapper.ToResponsesAsync(
|
||||
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
|
||||
|
||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
|
||||
}
|
||||
|
||||
return responses;
|
||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,38 +3,37 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Mixes.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Mixes
|
||||
namespace Musify.Application.Mixes;
|
||||
|
||||
|
||||
internal static class MixItemMapper
|
||||
{
|
||||
|
||||
internal static class MixItemMapper
|
||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||
IDatabase database,
|
||||
IReadOnlyList<MixItem> orderedItems,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||
IDatabase database,
|
||||
IReadOnlyList<MixItem> orderedItems,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
||||
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))
|
||||
.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))
|
||||
.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,11 @@
|
||||
namespace Musify.Application.Mixes.Responses
|
||||
namespace Musify.Application.Mixes.Responses;
|
||||
|
||||
public record MixApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Subtitle,
|
||||
int ItemCount,
|
||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||
{
|
||||
public record MixApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Subtitle,
|
||||
int ItemCount,
|
||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||
{
|
||||
public const int CoverItemCount = 4;
|
||||
}
|
||||
public const int CoverItemCount = 4;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user