From c14db5a7113e5c8e7762269b86fb1685b0e20934 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 17 Sep 2026 17:43:27 +0200 Subject: [PATCH] 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. --- .../Albums/GetAlbumTracks.cs | 1 + .../Musify.Application/Contracts/IDatabase.cs | 2 + .../Likes/GetLikedTracks.cs | 1 + .../Musify.Application/Mixes/MixItemMapper.cs | 12 +- .../Responses/MixItemApplicationResponse.cs | 3 +- .../PlayLists/GetPlayListTracks.cs | 1 + .../Musify.Application/Tracks/CreateTrack.cs | 24 +- .../Tracks/GetListeningHistory.cs | 1 + .../Musify.Application/Tracks/GetTrackById.cs | 1 + .../Musify.Application/Tracks/GetTracks.cs | 1 + .../Tracks/GetTracksByUserId.cs | 1 + .../Responses/TrackApplicationResponse.cs | 6 +- backend/Core/Musify.Domain/Entities/Track.cs | 2 + .../Core/Musify.Domain/Entities/TrackTag.cs | 22 + .../Core/Musify.Domain/ValueObjects/Genre.cs | 32 + .../ValueObjects/GenreCompatibility.cs | 54 + .../Persistence/Database.cs | 14 +- .../20260916153700_AddTrackTags.Designer.cs | 1168 +++++++++++++++++ .../Migrations/20260916153700_AddTrackTags.cs | 54 + .../Migrations/DatabaseModelSnapshot.cs | 35 + .../Tracks/CreateTrackRequest.cs | 5 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 2 +- .../Tracks/CreateTrackRequestValidator.cs | 14 + .../TestSupport/TestDatabase.cs | 12 + .../TestSupport/TestEntities.cs | 13 +- .../Tracks/CreateTrackCommandHandlerTests.cs | 37 +- .../ValueObjects/GenreCompatibilityTests.cs | 51 + .../Persistence/DatabaseTests.cs | 25 + web-player/src/lib/api/schema.d.ts | 2 + .../lib/components/AlbumContextMenu.svelte | 6 +- .../src/lib/components/MobileHeader.svelte | 4 +- web-player/src/lib/components/Sidebar.svelte | 23 +- web-player/src/lib/components/TabsBar.svelte | 4 +- web-player/src/lib/components/TopBar.svelte | 10 +- .../lib/components/TrackContextMenu.svelte | 4 +- .../lib/components/player/PlayerBar.svelte | 4 +- .../lib/components/player/PlayerDock.svelte | 8 +- .../lib/components/player/PlayerExtras.svelte | 2 +- .../src/lib/components/player/Queue.svelte | 22 +- .../src/lib/components/player/SeekBar.svelte | 2 +- .../lib/components/player/TrackInfo.svelte | 4 +- .../player/TransportControls.svelte | 4 +- .../components/player/VolumeControl.svelte | 2 +- .../src/lib/components/ui/AlbumForm.svelte | 2 +- web-player/src/lib/components/ui/Alert.svelte | 6 +- .../src/lib/components/ui/ArtistAvatar.svelte | 2 +- .../src/lib/components/ui/Button.svelte | 2 +- .../src/lib/components/ui/EmptyState.svelte | 6 +- .../src/lib/components/ui/EqBars.svelte | 2 +- .../src/lib/components/ui/GlassMenu.svelte | 2 +- .../src/lib/components/ui/MediaCard.svelte | 4 +- .../src/lib/components/ui/MenuItem.svelte | 4 +- .../src/lib/components/ui/MixTile.svelte | 4 +- web-player/src/lib/components/ui/Modal.svelte | 4 +- .../src/lib/components/ui/NowPlaying.svelte | 6 +- .../src/lib/components/ui/PlaylistCard.svelte | 2 +- .../src/lib/components/ui/PlaylistForm.svelte | 4 +- .../lib/components/ui/SearchResultRow.svelte | 8 +- .../lib/components/ui/SectionHeading.svelte | 2 +- .../src/lib/components/ui/TrackTable.svelte | 14 +- .../lib/components/ui/TrackTitleCell.svelte | 15 +- web-player/src/lib/config.ts | 2 +- web-player/src/lib/format.ts | 7 +- web-player/src/lib/mixes.ts | 3 +- web-player/src/lib/player/liked.svelte.ts | 2 + web-player/src/lib/player/player.svelte.ts | 9 +- web-player/src/lib/recentlyPlayed.ts | 6 +- web-player/src/lib/theme/theme.css | 35 +- web-player/src/lib/theme/tokens.ts | 50 +- web-player/src/lib/tracks.ts | 14 +- .../albums/[id]/components/AlbumHeader.svelte | 2 +- .../[id]/components/AlbumTrackTable.svelte | 10 +- .../[id]/components/LibraryPicker.svelte | 5 +- .../src/routes/(app)/explore/+page.svelte | 23 +- .../src/routes/(app)/library/+page.svelte | 6 +- .../src/routes/(app)/liked/+page.svelte | 8 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 6 +- .../src/routes/(app)/playlists/+page.svelte | 2 +- .../[id]/components/PlaylistHeader.svelte | 10 +- .../[id]/components/PlaylistTrackTable.svelte | 12 +- web-player/src/routes/+page.svelte | 109 +- web-player/src/routes/layout.css | 12 +- web-player/src/routes/login/+page.svelte | 3 +- 83 files changed, 1872 insertions(+), 248 deletions(-) create mode 100644 backend/Core/Musify.Domain/Entities/TrackTag.cs create mode 100644 backend/Core/Musify.Domain/ValueObjects/Genre.cs create mode 100644 backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.cs create mode 100644 backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index cd315f5..9d80336 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -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); diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index 71bcda0..df8ebe6 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -31,6 +31,8 @@ namespace Musify.Application.Contracts DbSet TrackLikes { get; } + DbSet TrackTags { get; } + DbSet UserFollows { get; } Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index 4981c3a..3cd61e0 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -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); diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index 3017594..66cafcb 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -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(); } diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs index 409315a..1b75458 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -4,5 +4,6 @@ namespace Musify.Application.Mixes.Responses Guid TrackId, string Title, string? Artist, - double DurationSeconds); + double DurationSeconds, + int ListensCount); } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 6f04d1a..f5e19e1 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -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); diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 48db68b..80cc7c3 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -17,7 +17,8 @@ namespace Musify.Application.Tracks long UserId, string Title, Guid PictureIntentId, - Guid AudioIntentId) + Guid AudioIntentId, + IReadOnlyCollection Tags) : ICommand>; 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 diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 080b9db..5efcaf4 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -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); diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 9c4c10a..86c7c67 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -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); diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 9233a70..c2b614b 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -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)) diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 9babb03..4a8d585 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -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)) diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 8895b63..02f87de 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -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 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()); } } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index 4d6a6d1..439970e 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -48,5 +48,7 @@ namespace Musify.Domain.Entities public ICollection AlbumTracks { get; set; } = new List(); public ICollection ListeningHistories { get; set; } = new List(); + + public ICollection Tags { get; set; } = new List(); } } diff --git a/backend/Core/Musify.Domain/Entities/TrackTag.cs b/backend/Core/Musify.Domain/Entities/TrackTag.cs new file mode 100644 index 0000000..16fece7 --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/TrackTag.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + public class TrackTag + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required Guid TrackId { get; set; } + + [Required] + public required Genre Tag { get; set; } + + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/Genre.cs b/backend/Core/Musify.Domain/ValueObjects/Genre.cs new file mode 100644 index 0000000..dc9eeb9 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/Genre.cs @@ -0,0 +1,32 @@ +namespace Musify.Domain.ValueObjects +{ + public enum Genre + { + Pop = 0, + Rock = 1, + HipHop = 2, + RnB = 3, + Jazz = 4, + Blues = 5, + Classical = 6, + Electronic = 7, + House = 8, + Techno = 9, + Trance = 10, + Dubstep = 11, + DrumAndBass = 12, + Metal = 13, + Punk = 14, + Reggae = 15, + Reggaeton = 16, + Country = 17, + Folk = 18, + Indie = 19, + KPop = 20, + Latin = 21, + Soul = 22, + Funk = 23, + Ambient = 24, + Lofi = 25 + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs new file mode 100644 index 0000000..7d6e0e6 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs @@ -0,0 +1,54 @@ +namespace Musify.Domain.ValueObjects +{ + /// + /// Encodes which genre pairs are mutually exclusive on a single track (e.g. a track cannot + /// be tagged as both Classical and Metal at the same time). + /// + public static class GenreCompatibility + { + private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); + + private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() + { + var pairs = new HashSet<(Genre, Genre)>(); + + void Add(Genre a, Genre b) + { + pairs.Add((a, b)); + pairs.Add((b, a)); + } + + var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass }; + var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; + + foreach (var intenseGenre in intense) + foreach (var mellowGenre in mellow) + Add(intenseGenre, mellowGenre); + + return pairs; + } + + public static bool AreCompatible(Genre a, Genre b) => + a == b || !IncompatiblePairs.Contains((a, b)); + + /// + /// Returns every mutually exclusive genre pair found within the given set of tags. + /// + public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable tags) + { + var distinctTags = tags.Distinct().ToList(); + var conflicts = new List<(Genre, Genre)>(); + + for (var i = 0; i < distinctTags.Count; i++) + { + for (var j = i + 1; j < distinctTags.Count; j++) + { + if (!AreCompatible(distinctTags[i], distinctTags[j])) + conflicts.Add((distinctTags[i], distinctTags[j])); + } + } + + return conflicts; + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 229e156..10ebf78 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - optionsBuilder.UseNpgsql(configuration.ConnectionString); + optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -128,6 +128,16 @@ namespace Musify.Infrastructure.Persistence .HasIndex(like => new { like.UserId, like.TrackId }) .IsUnique(); + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); + modelBuilder.Entity() .HasOne(follow => follow.Follower) .WithMany() @@ -181,6 +191,8 @@ namespace Musify.Infrastructure.Persistence public DbSet TrackLikes => Set(); + public DbSet TrackTags => Set(); + public DbSet UserFollows => Set(); public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.Designer.cs new file mode 100644 index 0000000..aedc4e8 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.Designer.cs @@ -0,0 +1,1168 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260916153700_AddTrackTags")] + partial class AddTrackTags + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + 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.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.cs new file mode 100644 index 0000000..73b7a54 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260916153700_AddTrackTags.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Musify.Domain.ValueObjects; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddTrackTags : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance"); + + migrationBuilder.CreateTable( + name: "TrackTags", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + Tag = table.Column(type: "genre", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackTags", x => x.Id); + table.ForeignKey( + name: "FK_TrackTags_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrackTags_TrackId_Tag", + table: "TrackTags", + columns: new[] { "TrackId", "Tag" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrackTags"); + + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 9c9c158..f2db7eb 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -3,6 +3,7 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; using Musify.Infrastructure.Persistence; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; @@ -20,6 +21,7 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => @@ -476,6 +478,26 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackLikes"); }); + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => { b.Property("Id") @@ -1041,6 +1063,17 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => { b.HasOne("Musify.Domain.Entities.User", "User") @@ -1113,6 +1146,8 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("PlayListTracks"); + b.Navigation("Tags"); + b.Navigation("UserTracks"); }); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs index 4922e18..6623318 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -1,6 +1,9 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Api.DataTransferObjects.Tracks; public record CreateTrackRequest( string Title, Guid PictureIntentId, - Guid AudioIntentId); + Guid AudioIntentId, + IReadOnlyCollection Tags); diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 3a44359..b285245 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -147,7 +147,7 @@ public static class TrackEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId), + new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags), cancellationToken); return result.ToCreatedResult(track => $"/tracks/{track.Id}"); diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs index 1030057..6584cff 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; +using Musify.Domain.ValueObjects; namespace Musify.Api.Validators.Tracks; @@ -16,5 +17,18 @@ public sealed class CreateTrackRequestValidator : AbstractValidator x.AudioIntentId) .NotEmpty(); + + RuleFor(x => x.Tags) + .NotEmpty() + .WithMessage("At least one tag is required."); + + RuleFor(x => x.Tags) + .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) + .When(x => x.Tags is { Count: > 0 }) + .WithMessage(x => + { + var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); + return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; + }); } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index b273b02..5dd2b39 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -95,6 +95,16 @@ namespace Musify.Application.Tests.TestSupport .HasIndex(like => new { like.UserId, like.TrackId }) .IsUnique(); + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); + modelBuilder.Entity() .HasOne(follow => follow.Follower) .WithMany() @@ -138,6 +148,8 @@ namespace Musify.Application.Tests.TestSupport public DbSet TrackLikes => Set(); + public DbSet TrackTags => Set(); + public DbSet UserFollows => Set(); public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 1716c54..3dce496 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -55,8 +55,10 @@ namespace Musify.Application.Tests.TestSupport double durationSeconds = 180, TrackPictures? pictures = null, TrackAudio? audio = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) => - new() + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + IEnumerable? tags = null) + { + var track = new Track { Title = title, NormalizedTitle = title.ToUpperInvariant(), @@ -68,6 +70,13 @@ namespace Musify.Application.Tests.TestSupport Audio = audio ?? ProcessedAudio() }; + track.Tags = (tags ?? [Genre.Pop]) + .Select(tag => new TrackTag { TrackId = track.Id, Tag = tag }) + .ToList(); + + return track; + } + public static PlayListPictures PlayListPictures() => new() { OriginalName = "original.webp", diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index 55898d1..de7ebbb 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -42,7 +42,7 @@ namespace Musify.Application.Tests.Tracks var (picture, audio) = SeedIntents(user.Id); await SeedAsync(user, picture, audio); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -50,6 +50,7 @@ namespace Musify.Application.Tests.Tracks Assert.Equal("My Song", result.Value.Title); var localResponse = Assert.IsType(result.Value); Assert.Equal(user.Id, localResponse.OwnerUserId); + Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags); await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); @@ -57,10 +58,38 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); } + [Fact] + public async Task Handle_NoTags_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task Handle_IncompatibleTags_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + [Fact] public async Task Handle_UserMissing_ReturnsNotFound() { - var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid()); + var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -75,7 +104,7 @@ namespace Musify.Application.Tests.Tracks var (picture, audio) = SeedIntents(otherUser.Id); await SeedAsync(user, otherUser, picture, audio); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); @@ -91,7 +120,7 @@ namespace Musify.Application.Tests.Tracks var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed); await SeedAsync(user, picture, audio); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs new file mode 100644 index 0000000..1aa3589 --- /dev/null +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs @@ -0,0 +1,51 @@ +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Domain.Tests.ValueObjects +{ + public sealed class GenreCompatibilityTests + { + [Theory] + [InlineData(Genre.Classical, Genre.Metal)] + [InlineData(Genre.Classical, Genre.Punk)] + [InlineData(Genre.Ambient, Genre.Dubstep)] + [InlineData(Genre.Lofi, Genre.DrumAndBass)] + public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second) + { + Assert.False(GenreCompatibility.AreCompatible(first, second)); + Assert.False(GenreCompatibility.AreCompatible(second, first)); + } + + [Theory] + [InlineData(Genre.Pop, Genre.Rock)] + [InlineData(Genre.HipHop, Genre.RnB)] + [InlineData(Genre.Classical, Genre.Jazz)] + public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second) + { + Assert.True(GenreCompatibility.AreCompatible(first, second)); + } + + [Fact] + public void AreCompatible_SameGenre_ReturnsTrue() + { + Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop)); + } + + [Fact] + public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]); + + Assert.Empty(conflicts); + } + + [Fact] + public void FindConflicts_IncompatiblePairPresent_ReturnsIt() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]); + + var conflict = Assert.Single(conflicts); + Assert.Equal((Genre.Classical, Genre.Metal), conflict); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs index 730b9b4..3265b9e 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -41,6 +41,31 @@ namespace Musify.Infrastructure.Tests.Persistence Assert.Equal(user.Id, stored.OwnerUserId); } + [Fact] + public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() + { + await using var database = fixture.CreateDatabase(); + + var userName = "tags-roundtrip-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}"); + track.Tags = + [ + new TrackTag { TrackId = track.Id, Tag = Genre.Pop }, + new TrackTag { TrackId = track.Id, Tag = Genre.Rock } + ]; + + await database.AddRangeAsync(user, track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking() + .Include(t => t.Tags) + .SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); + + Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t)); + } + [Fact] public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows() { diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 8d19371..aa1cc9a 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -743,6 +743,8 @@ export interface components { artist: null | string; /** Format: double */ durationSeconds: number | string; + /** Format: int32 */ + listensCount: number | string; }; PaginatedResponseOfAlbumApplicationResponse: { items: components["schemas"]["AlbumApplicationResponse"][]; diff --git a/web-player/src/lib/components/AlbumContextMenu.svelte b/web-player/src/lib/components/AlbumContextMenu.svelte index b7a769e..d55609e 100644 --- a/web-player/src/lib/components/AlbumContextMenu.svelte +++ b/web-player/src/lib/components/AlbumContextMenu.svelte @@ -55,7 +55,7 @@ onclick={onPlayNext} class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover" > - + Reproducir a continuación {#if playlists.length > 0} @@ -80,7 +80,7 @@ : 'left-full pl-1.5'}" >
{#each playlists as playlist (playlist.id)} diff --git a/web-player/src/lib/components/MobileHeader.svelte b/web-player/src/lib/components/MobileHeader.svelte index 8f29005..24aef1d 100644 --- a/web-player/src/lib/components/MobileHeader.svelte +++ b/web-player/src/lib/components/MobileHeader.svelte @@ -21,9 +21,7 @@ href="/" class="flex items-center gap-2 font-display text-lg font-semibold tracking-[-0.02em] text-fg" > - + Musify diff --git a/web-player/src/lib/components/Sidebar.svelte b/web-player/src/lib/components/Sidebar.svelte index ed415dc..72850fe 100644 --- a/web-player/src/lib/components/Sidebar.svelte +++ b/web-player/src/lib/components/Sidebar.svelte @@ -33,7 +33,7 @@ ? [ { href: '/', label: 'Inicio', icon: Home }, { href: '/explore', label: 'Descubrir', icon: Compass }, - { href: '/playlists', label: 'Playlists', icon: ListMusic }, + { href: '/playlists', label: 'Playlists', icon: ListMusic, count: playlists.length }, { href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count }, { href: '/albums', label: 'Mis álbumes', icon: Disc }, { href: '/library', label: 'Canciones subidas', icon: Folder }, @@ -48,24 +48,22 @@
-
+