Add likes, follows and playlist visibility; redesign web-player UI

Backend:
- New Likes feature (toggle/get liked tracks) and endpoints
- User follow/unfollow, listening stats, public user profile
- Playlist visibility (public/private) support
- Various auth and DTO/response adjustments to support the above

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
2026-09-14 23:42:45 +02:00
parent 389b3b9244
commit dc1ba89a6d
166 changed files with 14271 additions and 1982 deletions
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities;
using Musify.Application.Serialization;
namespace Musify.Application.Albums.Responses
{
@@ -17,7 +18,7 @@ namespace Musify.Application.Albums.Responses
string Title,
string? Description,
int? ReleaseYear,
long OwnerUserId,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
@@ -41,6 +41,10 @@ namespace Musify.Application.Contracts
DbSet<ListeningHistory> ListeningHistories { get; }
DbSet<TrackLike> TrackLikes { get; }
DbSet<UserFollow> UserFollows { get; }
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
@@ -0,0 +1,45 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Likes
{
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
var likesQuery = database.TrackLikes
.AsNoTracking()
.Include(like => ((ExternalTrack)like.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(like => ((LocalTrack)like.Track).Owner)
.Where(like => like.UserId == request.UserId);
var totalCount = await likesQuery.CountAsync(cancellationToken);
var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -0,0 +1,83 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(
long UserId,
Guid? TrackId,
TrackSource? Source,
string? ExternalId)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
YouTubeTrackProvisioner provisioner,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{
var trackIdResult = await ResolveTrackIdAsync(request, cancellationToken);
if (trackIdResult.IsError)
return trackIdResult.Errors;
var trackId = trackIdResult.Value;
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == trackId, cancellationToken);
bool nowLiked;
if (existingLike is not null)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = trackId
}, cancellationToken);
nowLiked = true;
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", trackId);
return nowLiked;
}
private async ValueTask<ErrorOr<Guid>> ResolveTrackIdAsync(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{
if (request.TrackId.HasValue)
{
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId.Value, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
return request.TrackId.Value;
}
var provisionResult = await provisioner.GetOrCreateAsync(request.ExternalId!, cancellationToken);
if (provisionResult.IsError)
return provisionResult.Errors;
return provisionResult.Value.Track.Id;
}
}
}
@@ -17,7 +17,8 @@ namespace Musify.Application.PlayLists
long UserId,
string Name,
string? Description,
Guid? PictureIntentId)
Guid? PictureIntentId,
PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
@@ -55,7 +56,8 @@ namespace Musify.Application.PlayLists
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(),
Pictures = resolvedPictures.Pictures
Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
@@ -3,10 +3,11 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists.Responses;
using Mediator;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists
{
public record GetPlayListByIdQuery(Guid Id)
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database)
@@ -31,6 +32,10 @@ namespace Musify.Application.PlayLists
if (entry is null)
return Error.NotFound();
if (entry.PlayList.Visibility == PlaylistVisibility.Private
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
}
}
@@ -14,7 +14,8 @@ namespace Musify.Application.PlayLists
long UserId,
string? Name,
int PageNumber,
int PageSize)
int PageSize,
bool OnlyPublic = false)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
@@ -26,6 +27,11 @@ namespace Musify.Application.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
if (request.OnlyPublic)
{
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
}
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
@@ -1,4 +1,5 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses
{
@@ -9,6 +10,7 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
PlaylistVisibility Visibility,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
@@ -26,6 +28,7 @@ namespace Musify.Application.PlayLists.Responses
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
@@ -18,7 +18,8 @@ namespace Musify.Application.PlayLists
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId)
Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
@@ -57,6 +58,11 @@ namespace Musify.Application.PlayLists
playListEntity.Description = request.NewDescription;
}
if (request.NewVisibility.HasValue)
{
playListEntity.Visibility = request.NewVisibility.Value;
}
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
@@ -0,0 +1,44 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Musify.Application.Serialization;
// Zitadel-issued user ids routinely exceed Number.MAX_SAFE_INTEGER (2^53-1).
// Serialized as a raw JSON number, the frontend's JSON.parse silently rounds
// them to the nearest representable double, corrupting the id (it becomes a
// different, sometimes valid, user id). Applied via [property: JsonConverter(...)]
// on the specific id-shaped `long`/`long?` response properties (not globally —
// unrelated `long` fields like byte sizes must stay numbers), this serializes
// them as a JSON string instead, which round-trips exactly.
public sealed class LongAsStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType == JsonTokenType.String
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
{
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return reader.TokenType == JsonTokenType.String
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
}
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
else
writer.WriteNullValue();
}
}
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
@@ -12,7 +14,7 @@ namespace Musify.Application.Tracks.Responses
int ListensCount,
DateTime CreatedAt,
DateTime UpdatedAt,
long OwnerUserId)
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
{
public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) =>
@@ -0,0 +1,50 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Users
{
public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
public class FollowUserCommandHandler(
IDatabase database,
ILogger<FollowUserCommandHandler> logger)
: ICommandHandler<FollowUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(FollowUserCommand request, CancellationToken cancellationToken)
{
if (request.FollowerId == request.FollowedId)
return Error.Validation(description: "A user cannot follow themselves.");
var followedExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
if (!followedExists)
{
logger.LogInformation("User {FollowedId} not found", request.FollowedId);
return Error.NotFound(description: $"User {request.FollowedId} not found");
}
var alreadyFollowing = await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (alreadyFollowing)
return new Success();
await database.UserFollows.AddAsync(new UserFollow
{
FollowerId = request.FollowerId,
FollowedId = request.FollowedId
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
}
}
}
@@ -0,0 +1,48 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
public class GetListeningStatsQueryHandler(IDatabase database)
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
{
public async ValueTask<ListeningStatsResponse> Handle(GetListeningStatsQuery request, CancellationToken cancellationToken)
{
var weekAgo = DateTime.UtcNow.AddDays(-7);
var thisWeekHistory = database.ListeningHistories
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory
.Select(l => l.TrackId)
.Distinct()
.CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks, l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
.SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories
.Where(l => l.UserId == request.UserId)
.Select(l => l.ListenedAt.Date)
.Distinct()
.ToListAsync(cancellationToken);
var listenedDateSet = listenedDates.ToHashSet();
var streakDays = 0;
var day = DateTime.UtcNow.Date;
while (listenedDateSet.Contains(day))
{
streakDays++;
day = day.AddDays(-1);
}
return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays);
}
}
}
@@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Serialization;
namespace Musify.Application.Users
{
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
DateTime CreatedAt,
int FollowersCount,
int FollowingCount);
public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
{
public async ValueTask<ErrorOr<UserProfileResponse>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
{
var user = await database.Users
.AsNoTracking()
.Where(u => u.Id == request.UserId)
.Select(u => new
{
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
u.CreatedAt
})
.SingleOrDefaultAsync(cancellationToken);
if (user is null)
return Error.NotFound();
var followersCount = await database.UserFollows
.AsNoTracking()
.CountAsync(f => f.FollowedId == request.UserId, cancellationToken);
var followingCount = await database.UserFollows
.AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
return new UserProfileResponse(
user.Id,
user.Name,
user.FirstName,
user.SecondName,
user.ProfilePictureUrl,
user.CreatedAt,
followersCount,
followingCount);
}
}
}
@@ -0,0 +1,17 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery<bool>;
public class IsFollowingUserQueryHandler(IDatabase database)
: IQueryHandler<IsFollowingUserQuery, bool>
{
public ValueTask<bool> Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) =>
new(database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken));
}
}
@@ -1,9 +1,11 @@
using Musify.Domain.Entities;
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
namespace Musify.Application.Users.Responses
{
public record UserApplicationResponse(
long Id,
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
@@ -0,0 +1,32 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
public class UnfollowUserCommandHandler(
IDatabase database,
ILogger<UnfollowUserCommandHandler> logger)
: ICommandHandler<UnfollowUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(UnfollowUserCommand request, CancellationToken cancellationToken)
{
var follow = await database.UserFollows
.SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (follow is null)
return new Success();
database.UserFollows.Remove(follow);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
}
}
}
@@ -33,6 +33,9 @@ namespace Musify.Domain.Entities
[Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private;
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("TrackLikes")]
public class TrackLike
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long UserId { get; set; }
[Required]
public required Guid TrackId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(UserId))]
public User User { get; set; }
[ForeignKey(nameof(TrackId))]
public Track Track { get; set; }
}
}
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("UserFollows")]
public class UserFollow
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long FollowerId { get; set; }
[Required]
public required long FollowedId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(FollowerId))]
public User Follower { get; set; }
[ForeignKey(nameof(FollowedId))]
public User Followed { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace Musify.Domain.ValueObjects
{
public enum PlaylistVisibility
{
Private = 0,
Public = 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 68 KiB

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using MassTransit;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Sagas;
using AppIDatabase = Musify.Application.Contracts.IDatabase;
@@ -88,6 +89,10 @@ namespace Musify.Infrastructure.Persistence
.HasIndex(artist => artist.ExternalId)
.IsUnique();
modelBuilder.Entity<PlayList>()
.Property(playList => playList.Visibility)
.HasDefaultValue(PlaylistVisibility.Private);
modelBuilder.Entity<PlayList>()
.OwnsOne(playList => playList.Pictures, pictures =>
{
@@ -159,6 +164,26 @@ namespace Musify.Infrastructure.Persistence
modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position });
modelBuilder.Entity<TrackLike>()
.HasIndex(like => new { like.UserId, like.TrackId })
.IsUnique();
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()
.HasForeignKey(follow => follow.FollowerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Followed)
.WithMany()
.HasForeignKey(follow => follow.FollowedId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
.IsUnique();
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
@@ -206,6 +231,10 @@ namespace Musify.Infrastructure.Persistence
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPlayListVisibility : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "PlayLists",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Visibility",
table: "PlayLists");
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackLikes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TrackLikes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TrackLikes", x => x.Id);
table.ForeignKey(
name: "FK_TrackLikes_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TrackLikes_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_TrackLikes_TrackId",
table: "TrackLikes",
column: "TrackId");
migrationBuilder.CreateIndex(
name: "IX_TrackLikes_UserId_TrackId",
table: "TrackLikes",
columns: new[] { "UserId", "TrackId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TrackLikes");
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserFollows : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserFollows",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FollowerId = table.Column<long>(type: "bigint", nullable: false),
FollowedId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserFollows", x => x.Id);
table.ForeignKey(
name: "FK_UserFollows_User_FollowedId",
column: x => x.FollowedId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_UserFollows_User_FollowerId",
column: x => x.FollowerId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_UserFollows_FollowedId",
table: "UserFollows",
column: "FollowedId");
migrationBuilder.CreateIndex(
name: "IX_UserFollows_FollowerId_FollowedId",
table: "UserFollows",
columns: new[] { "FollowerId", "FollowedId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserFollows");
}
}
}
@@ -431,6 +431,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<int>("Visibility")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.HasKey("Id");
b.HasIndex("UserId");
@@ -518,6 +523,31 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("TrackArtist");
});
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId", "TrackId")
.IsUnique();
b.ToTable("TrackLikes");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
@@ -636,6 +666,31 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("FollowedId")
.HasColumnType("bigint");
b.Property<long>("FollowerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("FollowedId");
b.HasIndex("FollowerId", "FollowedId")
.IsUnique();
b.ToTable("UserFollows");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
@@ -1105,6 +1160,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany()
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
@@ -1116,6 +1190,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
{
b.HasOne("Musify.Domain.Entities.User", "Followed")
.WithMany()
.HasForeignKey("FollowedId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "Follower")
.WithMany()
.HasForeignKey("FollowerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Followed");
b.Navigation("Follower");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
@@ -13,8 +13,8 @@ namespace Musify.Infrastructure.Services
{
public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger<YouTubeMusicService> logger) : IYouTubeMusicService
{
private readonly YouTubeMusicClient client = new(geographicalLocation: configuration.GeographicalLocation);
private readonly SingleFlightCache singleFlight = new(cache);
private readonly YouTubeMusicClient client = new YouTubeMusicClient(geographicalLocation: configuration.GeographicalLocation);
private readonly SingleFlightCache singleFlight = new SingleFlightCache(cache);
private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc);
@@ -89,7 +89,7 @@ namespace Musify.Infrastructure.Services
song.IsExplicit,
song.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
string.IsNullOrEmpty(artist.Id)? null: artist.Id,
artist.Name))
.ToList()))
.ToList();
@@ -147,7 +147,7 @@ namespace Musify.Infrastructure.Services
info.IsEp,
info.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
string.IsNullOrEmpty(artist.Id)? null: artist.Id,
artist.Name))
.ToList());
@@ -203,7 +203,7 @@ namespace Musify.Infrastructure.Services
{
var now = DateTime.UtcNow;
if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached)
&& cached is not null
&& cached != null
&& cached.ExpiresAtUtc > now)
{
return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds);
@@ -267,7 +267,7 @@ namespace Musify.Infrastructure.Services
info.IsExplicit,
info.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
string.IsNullOrEmpty(artist.Id)? null: artist.Id,
artist.Name))
.ToList());
}
@@ -291,8 +291,7 @@ namespace Musify.Infrastructure.Services
var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize)
?? candidates.LastOrDefault();
return chosen is null
? string.Empty
return chosen ==null? string.Empty
: YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize);
}