Checkpoint web-player design-system refactor and backend track genre tagging

Advances the ongoing token/componentization cleanup (anchored player dock,
border-based panels instead of floating shadows, corrected light-mode
accent/gradient tokens) alongside in-progress backend support for track
genre tags.
This commit is contained in:
2026-09-17 17:43:37 +02:00
parent 5ae9955bd0
commit c14db5a711
83 changed files with 1872 additions and 248 deletions
@@ -29,6 +29,7 @@ namespace Musify.Application.Albums
var tracksQuery = database.AlbumHasTracks
.AsNoTracking()
.Include(albumTrack => albumTrack.Track.Owner)
.Include(albumTrack => albumTrack.Track.Tags)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
@@ -31,6 +31,8 @@ namespace Musify.Application.Contracts
DbSet<TrackLike> TrackLikes { get; }
DbSet<TrackTag> TrackTags { get; }
DbSet<UserFollow> UserFollows { get; }
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
@@ -23,6 +23,7 @@ namespace Musify.Application.Likes
var likesQuery = database.TrackLikes
.AsNoTracking()
.Include(like => like.Track.Owner)
.Include(like => like.Track.Tags)
.Where(like => like.UserId == request.UserId);
var totalCount = await likesQuery.CountAsync(cancellationToken);
@@ -21,14 +21,20 @@ namespace Musify.Application.Mixes
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id))
.ToDictionaryAsync(track => track.Id, cancellationToken);
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
.Where(item => tracksById.ContainsKey(item.TrackId))
.Select(item =>
{
var track = tracksById[item.TrackId];
return new MixItemApplicationResponse(item.TrackId, track.Title, track.Owner.Name, track.DurationSeconds);
var entry = tracksById[item.TrackId];
return new MixItemApplicationResponse(
item.TrackId,
entry.Track.Title,
entry.Track.Owner.Name,
entry.Track.DurationSeconds,
entry.ListensCount);
})
.ToList();
}
@@ -4,5 +4,6 @@ namespace Musify.Application.Mixes.Responses
Guid TrackId,
string Title,
string? Artist,
double DurationSeconds);
double DurationSeconds,
int ListensCount);
}
@@ -29,6 +29,7 @@ namespace Musify.Application.PlayLists
var tracksQuery = database.PlayListHasTracks
.AsNoTracking()
.Include(plt => plt.Track.Owner)
.Include(plt => plt.Track.Tags)
.Where(plt => plt.PlayListId == request.PlayListId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
@@ -17,7 +17,8 @@ namespace Musify.Application.Tracks
long UserId,
string Title,
Guid PictureIntentId,
Guid AudioIntentId)
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler(
@@ -40,6 +41,23 @@ namespace Musify.Application.Tracks
return Error.NotFound(description: $"User {request.UserId} not found");
}
var distinctTags = request.Tags.Distinct().ToList();
if (distinctTags.Count == 0)
{
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
return Error.Validation(description: "At least one tag is required.");
}
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
if (tagConflicts.Count > 0)
{
var conflict = tagConflicts.First();
logger.LogWarning(
"Track {Title} was submitted with incompatible tags {First} and {Second}",
request.Title, conflict.First, conflict.Second);
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
}
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.PictureIntentId, request.UserId, cancellationToken);
@@ -80,6 +98,10 @@ namespace Musify.Application.Tracks
}
};
trackEntity.Tags = distinctTags
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
.ToList();
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.UserHasTracks.AddAsync(new UserHasTrack
@@ -25,6 +25,7 @@ namespace Musify.Application.Tracks
var tracksById = await database.Tracks
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
.ToDictionaryAsync(t => t.Id, cancellationToken);
@@ -16,6 +16,7 @@ namespace Musify.Application.Tracks
{
var entity = await database.Tracks.AsNoTracking()
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
.SingleOrDefaultAsync(cancellationToken);
@@ -19,6 +19,7 @@ namespace Musify.Application.Tracks
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.AsQueryable();
if (!string.IsNullOrEmpty(request.Name))
@@ -27,6 +27,7 @@ namespace Musify.Application.Tracks
var tracksQuery = database.UserHasTracks
.AsNoTracking()
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.Where(p => p.UserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name))
@@ -14,7 +14,8 @@ namespace Musify.Application.Tracks.Responses
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new(
@@ -26,6 +27,7 @@ namespace Musify.Application.Tracks.Responses
listensCount,
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId);
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList());
}
}