From 3f5e01f9102f80494aad1cfaf1547daa24632b38 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 20 Sep 2026 16:00:57 +0200 Subject: [PATCH] Update packages --- .../Configuration/PlayListConfiguration.cs | 22 - .../PlayLists/CreatePlayList.cs | 22 +- .../PlayLists/UpdatePlayList.cs | 10 +- .../Tracks/GetLastTrackListenedByUserId.cs | 29 + .../Musify.Application/Tracks/GetTracks.cs | 2 +- .../Core/Musify.Domain/Entities/PlayList.cs | 3 +- .../Assets/PlayListDefaultCover.png | Bin 70143 -> 0 bytes .../Infrastructure.csproj | 4 - .../Persistence/Database.cs | 8 +- ...PlayListPictureColumnsOptional.Designer.cs | 1164 +++++++++++++++++ ...5334_MakePlayListPictureColumnsOptional.cs | 106 ++ .../Migrations/DatabaseModelSnapshot.cs | 4 - .../Services/PlayListPresetSeeder.cs | 85 -- .../Services/ServicesDependencyInjection.cs | 6 - backend/Directory.Packages.props | 82 +- .../Musify.Api/Endpoints/UserEndpoints.cs | 15 + backend/Hosts/Musify.Worker/AppSettings.json | 4 +- backend/Hosts/Musify.Worker/Program.cs | 1 - .../Endpoints/UserEndpointsTests.cs | 62 + .../TestSupport/ApiTestFixture.cs | 1 - .../CreatePlayListCommandHandlerTests.cs | 4 +- .../GetPlayListCoverQueryHandlerTests.cs | 13 + .../TestSupport/TestDatabase.cs | 10 +- ...tTrackListenedByUserIdQueryHandlerTests.cs | 40 + .../Services/PlayListPresetSeederTests.cs | 103 -- web-player/docs/frontend-refactor-plan.md | 913 +++++++++++++ web-player/src/lib/components/Sidebar.svelte | 6 +- web-player/src/lib/components/TabsBar.svelte | 2 +- .../lib/components/player/PlayerDock.svelte | 4 +- .../src/lib/components/player/Queue.svelte | 8 +- .../src/lib/components/player/SeekBar.svelte | 2 +- .../lib/components/player/TrackInfo.svelte | 7 +- .../src/lib/components/ui/ArtistAvatar.svelte | 2 +- .../src/lib/components/ui/ArtistLink.svelte | 2 +- .../src/lib/components/ui/Button.svelte | 8 +- .../lib/components/ui/CollectionHeader.svelte | 4 +- web-player/src/lib/components/ui/Modal.svelte | 6 +- .../src/lib/components/ui/PageHeader.svelte | 2 +- .../src/lib/components/ui/PlaylistArt.svelte | 8 +- .../src/lib/components/ui/PlaylistForm.svelte | 6 +- .../lib/components/ui/TrackTitleCell.svelte | 2 +- web-player/src/lib/player/liked.svelte.ts | 6 +- web-player/src/lib/theme/theme.css | 44 +- web-player/src/lib/theme/tokens.ts | 8 +- .../src/routes/(app)/explore/+page.svelte | 4 +- .../src/routes/(app)/liked/+page.svelte | 14 +- .../src/routes/(app)/playlists/+page.svelte | 4 +- .../src/routes/(app)/u/[id]/+page.svelte | 5 +- .../src/routes/(app)/upload/+page.svelte | 4 +- web-player/src/routes/+layout.svelte | 2 +- web-player/src/routes/+page.svelte | 24 +- 51 files changed, 2508 insertions(+), 389 deletions(-) create mode 100644 backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs delete mode 100644 backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260917195334_MakePlayListPictureColumnsOptional.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260917195334_MakePlayListPictureColumnsOptional.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/PlayListPresetSeeder.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs create mode 100644 web-player/docs/frontend-refactor-plan.md diff --git a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs index 4e1eb7f..cb1ea32 100644 --- a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs @@ -12,10 +12,6 @@ namespace Musify.Application.Configuration [Required] public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); - - public bool SeedPresetPictures { get; set; } = true; - - public bool OverwritePresetPictures { get; set; } = false; } public class PlayListRoutes @@ -38,18 +34,6 @@ namespace Musify.Application.Configuration [Required] public string LargePicturesFolder { get; set; } = "LargePictures"; - [Required] - public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; - - [Required] - public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; - - [Required] - public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; - - [Required] - public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; - public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); @@ -58,12 +42,6 @@ namespace Musify.Application.Configuration public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); - public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, SmallPicturesFolder, PresetSmallPicture); - - public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, MediumPicturesFolder, PresetMediumPicture); - - public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, LargePicturesFolder, PresetLargePicture); - public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index e0598c7..de1e164 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists UploadIntentConfiguration uploadIntentConfiguration) : ICommandHandler> { - private sealed record ResolvedPlayListPictures(PlayListPictures Pictures, UploadIntent? Intent); + private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) { @@ -63,7 +63,7 @@ namespace Musify.Application.PlayLists if (resolvedPictures.Intent != null) { - var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures.OriginalName); + var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName); var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken); if (publishResult.IsError) return publishResult.Errors; @@ -88,13 +88,7 @@ namespace Musify.Application.PlayLists { if (!request.PictureIntentId.HasValue) { - return new ResolvedPlayListPictures(new PlayListPictures - { - OriginalName = playListConfiguration.Routes.PresetOriginalPicture, - SmallName = playListConfiguration.Routes.PresetSmallPicture, - MediumName = playListConfiguration.Routes.PresetMediumPicture, - LargeName = playListConfiguration.Routes.PresetLargePicture - }, null); + return new ResolvedPlayListPictures(null, null); } var validation = await uploadIntentValidator.ValidateAndLoadAsync( @@ -103,12 +97,12 @@ namespace Musify.Application.PlayLists if (validation.IsError) return validation.Errors; - return new ResolvedPlayListPictures(new PlayListPictures - { + return new ResolvedPlayListPictures(new PlayListPictures + { OriginalName = validation.Value.ObjectName, - SmallName = playListConfiguration.Routes.PresetSmallPicture, - MediumName = playListConfiguration.Routes.PresetMediumPicture, - LargeName = playListConfiguration.Routes.PresetLargePicture + SmallName = validation.Value.ObjectName, + MediumName = validation.Value.ObjectName, + LargeName = validation.Value.ObjectName }, validation.Value); } diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 3d97bdd..b3379cf 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -75,12 +75,12 @@ namespace Musify.Application.PlayLists return validation.Errors; pictureIntent = validation.Value; - playListEntity.Pictures = new PlayListPictures - { + playListEntity.Pictures = new PlayListPictures + { OriginalName = pictureIntent.ObjectName, - SmallName = playListConfiguration.Routes.PresetSmallPicture, - MediumName = playListConfiguration.Routes.PresetMediumPicture, - LargeName = playListConfiguration.Routes.PresetLargePicture + SmallName = pictureIntent.ObjectName, + MediumName = pictureIntent.ObjectName, + LargeName = pictureIntent.ObjectName }; finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs new file mode 100644 index 0000000..d3c322d --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks +{ + public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery>; + + public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + { + var track = await database.ListeningHistories + .Include(t => t.Track.Owner) + .Include(t => t.Track.Tags) + .Where(t => t.UserId == query.UserId) + .OrderByDescending(t => t.ListenedAt) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count)) + .FirstOrDefaultAsync(cancellationToken); + + return track == null + ? Error.NotFound("Track.NotFound", "No track found for the given user ID.") + : track; + } + } +} \ No newline at end of file diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index c2b614b..75d5353 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -33,7 +33,7 @@ namespace Musify.Application.Tracks var pagedEntities = await tracksQuery .OrderByDescending(t => t.Track.CreatedAt) .ThenBy(t => t.Id) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) + .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count } ) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var items = pagedEntities diff --git a/backend/Core/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs index 584fbb3..61ba628 100644 --- a/backend/Core/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -27,8 +27,7 @@ namespace Musify.Domain.Entities [MaxLength(256)] public string? Description { get; set; } - [Required] - public required PlayListPictures Pictures { get; set; } + public PlayListPictures? Pictures { get; set; } [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; diff --git a/backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png b/backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png deleted file mode 100644 index 0b7a38dba2e478d4a794ecc5bb7233ab3f0eebeb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70143 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 diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj index 5b85a9b..306716f 100644 --- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -44,8 +44,4 @@ - - - - diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 10ebf78..ad18df8 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -67,10 +67,10 @@ namespace Musify.Infrastructure.Persistence modelBuilder.Entity() .OwnsOne(playList => playList.Pictures, pictures => { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); }); modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260917195334_MakePlayListPictureColumnsOptional.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260917195334_MakePlayListPictureColumnsOptional.Designer.cs new file mode 100644 index 0000000..7e64f8f --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260917195334_MakePlayListPictureColumnsOptional.Designer.cs @@ -0,0 +1,1164 @@ +// +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("20260917195334_MakePlayListPictureColumnsOptional")] + partial class MakePlayListPictureColumnsOptional + { + /// + 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") + .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("SmallName") + .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/20260917195334_MakePlayListPictureColumnsOptional.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260917195334_MakePlayListPictureColumnsOptional.cs new file mode 100644 index 0000000..815ee9a --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260917195334_MakePlayListPictureColumnsOptional.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class MakePlayListPictureColumnsOptional : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SmallPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "MediumPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LargePictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SmallPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "OriginalPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "MediumPictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LargePictureName", + table: "PlayLists", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index f2db7eb..6f3e6d9 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -896,25 +896,21 @@ namespace Musify.Infrastructure.Persistence.Migrations .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"); diff --git a/backend/Core/Musify.Infrastructure/Services/PlayListPresetSeeder.cs b/backend/Core/Musify.Infrastructure/Services/PlayListPresetSeeder.cs deleted file mode 100644 index 69db5eb..0000000 --- a/backend/Core/Musify.Infrastructure/Services/PlayListPresetSeeder.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Reflection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.Services -{ - public class PlayListPresetSeeder(IServiceScopeFactory scopeFactory, ILogger logger) - : BackgroundService - { - private const string SourceResourceName = "Musify.Infrastructure.Assets.PlayListDefaultCover.png"; - private const string WebpContentType = "image/webp"; - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - using var scope = scopeFactory.CreateScope(); - var services = scope.ServiceProvider; - - var playListConfiguration = services.GetRequiredService(); - if (!playListConfiguration.SeedPresetPictures) - return; - - try - { - await SeedAsync(services, playListConfiguration, stoppingToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to seed playlist preset pictures"); - } - } - - private async Task SeedAsync(IServiceProvider services, PlayListConfiguration configuration, CancellationToken cancellationToken) - { - var storage = services.GetRequiredService(); - var pictures = services.GetRequiredService(); - var bucket = services.GetRequiredService().Bucket; - - var source = LoadSourceBytes(); - - var routes = configuration.Routes; - var sizes = configuration.PicturesSizes; - - var targets = new (string Key, int Width, int Height)[] - { - (routes.BuildOriginalPicturePath(routes.PresetOriginalPicture), sizes.LargePictureWidth, sizes.LargePictureHeight), - (routes.PresetSmallPicturePath, sizes.SmallPictureWidth, sizes.SmallPictureHeight), - (routes.PresetMediumPicturePath, sizes.MediumPictureWidth, sizes.MediumPictureHeight), - (routes.PresetLargePicturePath, sizes.LargePictureWidth, sizes.LargePictureHeight), - }; - - foreach (var target in targets) - { - if (!configuration.OverwritePresetPictures) - { - var existing = await storage.HeadObjectAsync(bucket, target.Key, cancellationToken); - if (existing is not null) - { - logger.LogDebug("Playlist preset picture already present at {Bucket}/{Key}", bucket, target.Key); - continue; - } - } - - using var sourceStream = new MemoryStream(source, writable: false); - await using var resized = await pictures.ResizePictureAsWebpAsync(sourceStream, target.Width, target.Height, cancellationToken); - await storage.UploadFileAsync(resized, WebpContentType, bucket, target.Key, cancellationToken); - - logger.LogInformation("Seeded playlist preset picture {Bucket}/{Key} ({Width}x{Height})", bucket, target.Key, target.Width, target.Height); - } - } - - private static byte[] LoadSourceBytes() - { - var assembly = Assembly.GetExecutingAssembly(); - using var stream = assembly.GetManifestResourceStream(SourceResourceName) - ?? throw new InvalidOperationException($"Embedded resource '{SourceResourceName}' was not found."); - - using var memory = new MemoryStream(); - stream.CopyTo(memory); - return memory.ToArray(); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 6151b16..109d631 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -46,12 +46,6 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } - public static IServiceCollection AddPlayListPresetSeeder(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddHostedService(); - return serviceDescriptors; - } - public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 55b7a8b..f4282db 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -1,68 +1,64 @@ - true - - + - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - + - - - - - + + + + + - - + - - - - - + + + + + - + - - + \ No newline at end of file diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index e2cc12c..c1c97f3 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -40,6 +40,12 @@ public static class UserEndpoints .WithSummary("Get A User'S Listening Stats For The Current Week And Streak.") .Produces(); + group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId) + .WithName("GetLastTrackListenedByUserId") + .WithSummary("Get The Last Track A User Listened To.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/profile", GetUserProfile) .WithName("GetUserProfile") .WithSummary("Get A User'S Public Profile, Including Follower Counts.") @@ -116,6 +122,15 @@ public static class UserEndpoints return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); } + private static async Task GetLastTrackListenedByUserId( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } + private static async Task GetUserProfile( IMediator mediator, long id, diff --git a/backend/Hosts/Musify.Worker/AppSettings.json b/backend/Hosts/Musify.Worker/AppSettings.json index 724563d..5a93fc4 100644 --- a/backend/Hosts/Musify.Worker/AppSettings.json +++ b/backend/Hosts/Musify.Worker/AppSettings.json @@ -44,9 +44,7 @@ "MediumPictureHeight": 256, "LargePictureWidth": 512, "LargePictureHeight": 512 - }, - "SeedPresetPictures": true, - "OverwritePresetPictures": false + } }, "Track": { "Routes": { diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index d5cf800..b85137a 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -30,7 +30,6 @@ services.AddStorageService(configuration); services.AddAudioTranscoder(configuration); services.AddHttpClient(); services.AddPictureService(configuration); -services.AddPlayListPresetSeeder(configuration); services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); services.AddUploadIntentJobs(configuration); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index a7e0fc3..e398f2e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -1,8 +1,15 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; +using Musify.Application.Tracks.Responses; using Musify.Application.Users.Responses; +using Musify.Domain.Entities; +using Musify.Infrastructure.Persistence; using Xunit; namespace Musify.Api.Tests.Endpoints @@ -10,6 +17,33 @@ namespace Musify.Api.Tests.Endpoints [Collection(ApiCollection.Name)] public sealed class UserEndpointsTests(ApiTestFixture fixture) { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = await database.Users.SingleAsync(u => u.Id == userId); + var track = new Track + { + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner + }; + database.Tracks.Add(track); + database.ListeningHistories.Add(new ListeningHistory + { + UserId = userId, + TrackId = track.Id, + ListenedAt = listenedAt + }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + } + [Fact] public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() { @@ -70,5 +104,33 @@ namespace Musify.Api.Tests.Endpoints Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); + await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); + + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal("Newer listen", body?.Title); + } + + [Fact] + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index d9697cb..c0272af 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -78,7 +78,6 @@ namespace Musify.Api.Tests.TestSupport ["InfrastructureStorage:UseHttp"] = "true", ["ApplicationStorage:Bucket"] = "musify-api-test", ["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", - ["PlayList:SeedPresetPictures"] = "false", }); }); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index c7c080d..1ef27a2 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -23,7 +23,7 @@ namespace Musify.Application.Tests.PlayLists TestConfigurations.UploadIntent()); [Fact] - public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures() + public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() { var user = TestEntities.User(); await SeedAsync(user); @@ -34,7 +34,7 @@ namespace Musify.Application.Tests.PlayLists Assert.False(result.IsError); Assert.Equal("My Playlist", result.Value.Name); - Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName); + Assert.Null(result.Value.SmallImageKeyName); await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs index 591322d..6c7dbbe 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs @@ -34,5 +34,18 @@ namespace Musify.Application.Tests.PlayLists Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PlayListWithoutPictures_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + playList.Pictures = null; + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 5dd2b39..177c128 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -45,8 +45,14 @@ namespace Musify.Application.Tests.TestSupport .HasForeignKey(track => track.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity().OwnsOne(playList => playList.Pictures); - modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(); + modelBuilder.Entity().OwnsOne(playList => playList.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).IsRequired(false); + pictures.Property(p => p.SmallName).IsRequired(false); + pictures.Property(p => p.MediumName).IsRequired(false); + pictures.Property(p => p.LargeName).IsRequired(false); + }); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); modelBuilder.Entity().OwnsOne(album => album.Pictures); modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs new file mode 100644 index 0000000..07d1fdf --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -0,0 +1,40 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase + { + private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack() + { + var owner = TestEntities.User(); + var older = TestEntities.Track(owner, "Older listen"); + var newer = TestEntities.Track(owner, "Newer listen"); + await SeedAsync( + owner, older, newer, + TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), + TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); + + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("Newer listen", result.Value.Title); + } + + [Fact] + public async Task Handle_NoHistory_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs deleted file mode 100644 index 6cbcfb3..0000000 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.Services; -using Musify.Infrastructure.Tests.TestSupport; -using Xunit; - -namespace Musify.Infrastructure.Tests.Services -{ - [Collection(InfrastructureCollection.Name)] - public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture) - { - private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new() - { - Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" }, - SeedPresetPictures = true, - OverwritePresetPictures = overwritePresetPictures - }; - - private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration) - { - var services = new ServiceCollection(); - services.AddSingleton(configuration); - services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket }); - services.AddSingleton(_ => new StorageService( - fixture.CreateDatabase(), - fixture.CreateS3Client(), - NullLogger.Instance, - new InfrastructureStorageConfiguration - { - Address = fixture.S3ServiceUrl, - AccessKey = InfrastructureTestFixture.S3AccessKey, - SecretAccessKey = InfrastructureTestFixture.S3SecretKey, - ForcePathStyle = true, - UseHttp = true, - })); - services.AddSingleton(new PictureService(NullLogger.Instance)); - - var provider = services.BuildServiceProvider(); - var seeder = new PlayListPresetSeeder(provider.GetRequiredService(), NullLogger.Instance); - return (seeder, provider.GetRequiredService()); - } - - private static async Task RunToCompletionAsync(PlayListPresetSeeder seeder) - { - await seeder.StartAsync(TestContext.Current.CancellationToken); - await (seeder.ExecuteTask ?? Task.CompletedTask); - } - - [Fact] - public async Task ExecuteAsync_SeedsAllFourPresetPictures() - { - var configuration = NewConfiguration(overwritePresetPictures: false); - var (seeder, storage) = Build(configuration); - - await RunToCompletionAsync(seeder); - - var routes = configuration.Routes; - foreach (var key in new[] - { - routes.BuildOriginalPicturePath(routes.PresetOriginalPicture), - routes.PresetSmallPicturePath, - routes.PresetMediumPicturePath, - routes.PresetLargePicturePath - }) - { - var metadata = await storage.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - Assert.True(metadata is not null, $"expected {key} to have been seeded"); - } - } - - [Fact] - public async Task ExecuteAsync_RunTwiceWithoutOverwrite_StaysIdempotent() - { - var configuration = NewConfiguration(overwritePresetPictures: false); - var (firstSeeder, storage) = Build(configuration); - await RunToCompletionAsync(firstSeeder); - - var (secondSeeder, _) = Build(configuration); - await RunToCompletionAsync(secondSeeder); - - var metadata = await storage.HeadObjectAsync( - InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken); - Assert.NotNull(metadata); - } - - [Fact] - public async Task ExecuteAsync_SeedPresetPicturesDisabled_UploadsNothing() - { - var configuration = NewConfiguration(overwritePresetPictures: false); - configuration.SeedPresetPictures = false; - var (seeder, storage) = Build(configuration); - - await RunToCompletionAsync(seeder); - - var metadata = await storage.HeadObjectAsync( - InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken); - Assert.Null(metadata); - } - } -} diff --git a/web-player/docs/frontend-refactor-plan.md b/web-player/docs/frontend-refactor-plan.md new file mode 100644 index 0000000..7abcd56 --- /dev/null +++ b/web-player/docs/frontend-refactor-plan.md @@ -0,0 +1,913 @@ +# Plan — Refactor y unificación del frontend (web-player) + +> Plan de trabajo para agentes/IA con contexto limitado (~128k). Sustituye a +> `design-system-refactor-plan.md` (cuyo contenido ya está mayormente aplicado). +> Basado en una lectura completa de **todos** los `.svelte`, `.css` y `.ts` de cliente +> de `web-player/src` (2026-09-18, rama `design-prot`). + +## Cómo usar este documento + +1. **Lee siempre la sección A (Contexto común)** — es corta y todas las partes la asumen. +2. Elige **una parte** (P0…P14). Cada parte indica: objetivo, prerrequisitos, archivos + a leer, tareas, API exacta de lo que se crea, tabla de mapeo y criterios de cierre. +3. Una parte = un commit/PR. No mezcles partes. +4. Si una parte necesita algo de otra (p. ej. un token o un helper) y **no existe + todavía**, créalo tú siguiendo la especificación de la sección B — están descritos con + ruta y API exactas para que cualquier parte pueda crearlos de forma idéntica. +5. Al terminar una parte, marca su casilla en la sección D (Registro). + +Orden recomendado (las flechas son dependencias **blandas**, ver punto 4): + +``` +P0 → P1 → P2 + ↘ P3 (Artwork/Avatar) ─┐ + ↘ P4 (Botones) ───────┤ + ↘ P5 (Helpers TS) ─────┼→ P6 (Menús) → P7 (Filas) → P8 (Tablas) → P9 (Tarjetas) + └→ P10 (Cabeceras) → P11 (Formularios) → P12 (Chrome) → P13 (Páginas) → P14 (Barrido final) +``` + +--- + +## A. Contexto común (leer siempre) + +### A.1 Stack y reglas del proyecto (de `web-player/CLAUDE.md`) + +- SvelteKit 2 + **Svelte 5 runes** (`$props()` con `interface Props`, `$state`, + `$derived`, `$effect`; nada de `export let` ni `$:`; `{#each}` siempre con clave). +- **Tailwind 4**: no hay `tailwind.config.js`; la config vive en `src/routes/layout.css` + (`@theme`, `@utility`). Tokens de color crudos en `src/lib/theme/theme.css` (`--mf-*`), + tokens dinámicos por hue en `src/lib/theme/tokens.ts` (`buildThemeTokens`). +- Sin comentarios en código salvo imprescindibles. +- Cierre de cada parte: `npm run check` y `npm run lint` **limpios** (desde `web-player/`). +- Iconos: `@lucide/svelte/icons/`. + +### A.2 Gotchas conocidos (no reintroducirlos) + +1. **Tokens animados por hue** (`--mf-bg`, `--mf-elevated`, `--mf-panel-bg`, `--mf-bar-bg`, + `--mf-hairline`, `--mf-hero-bg`, `--mf-modal-glow`, `--mf-spotlight-bg`, + `--mf-logo-grad`, `--mf-cover-grad`, `--mf-accent*`, `--mf-ambient`) se aplican como + **estilo inline** en `` desde `+layout.svelte`. Un override en + `:root[data-theme='light']` **no gana**: la variante clara va en `buildThemeTokens()`. +2. **Conflictos de clases Tailwind**: nunca `class="bg-a {cond ? 'bg-b' : ''}"`. Siempre + mutuamente excluyente: `class={cond ? 'bg-b' : 'bg-a'}`. Overrides de la clase base de + un componente compartido → prop/variante, no `!important`. +3. Texto sin clase de tamaño hereda 16px del navegador. Todo texto visible debe tener una + receta tipográfica (ver B.1.6). +4. `--mf-ink` es "texto sobre CTA" y se invierte en claro; `--mf-tile-shade`, + `--mf-cover-grad`, `--mf-liked-grad` son "arte" (no invertir). +5. Referencia visual: **Inicio** (`src/routes/+page.svelte`). Un cambio de refactor no + debe cambiar el aspecto salvo donde la parte lo diga explícitamente (columna "Δ"). + +### A.3 Verificación de cada parte + +```sh +cd web-player +npm run check +npm run lint +``` + +Visual (Browser pane, `.claude/launch.json` → `musify-web`; login de dev en la memoria del +proyecto): abre cada página tocada **y** Inicio; prueba estado activo (una canción +sonando), hover, móvil (375px) y modo claro (menú de cuenta → "Modo blanco"). + +### A.4 Línea base medida (para saber si avanzas) + +| Métrica | Hoy | +| ------------------------------------------------- | --------------------------------------- | +| Componentes `.svelte` (lib + rutas `components/`) | 61 | +| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 | +| Clases arbitrarias `x-[…]` | 44 | +| Espaciados fraccionarios (`h-3.75`, `gap-2.75`…) | ~190 apariciones, 60 valores distintos | +| Radios distintos usados | 13 (6 tokens + 7 de Tailwind por defecto) | +| `strokeWidth` distintos | 13 valores | +| Atributos `style="…"` | 24 | +| Pluralizaciones a mano `=== 1 ?` | 15 | +| Errores de `eslint` | 2 (`liked/+page.svelte`) | + +Objetivo global: ~61 → ~45 componentes, −1.500/−2.000 líneas, 0 arbitrarios fuera de una +lista blanca, 1 escala por dimensión. + +--- + +## B. Especificaciones compartidas (fuente única) + +Cualquier parte que necesite algo de aquí y no exista, lo crea **exactamente así**. + +### B.1 Tokens (en `src/routes/layout.css`, dentro de `@theme`) + +Tailwind 4 genera utilidades a partir de estos namespaces: `--radius-*` → `rounded-*`, +`--spacing-*` → `size-* w-* h-* p-* m-* gap-*`, `--container-*` → `max-w-*`, +`--tracking-*` → `tracking-*`, `--leading-*` → `leading-*`. + +#### B.1.1 Radios (`--radius-*`) + +| Token | Valor | Para qué | +| ----------------- | ---------- | ----------------------------------------- | +| `rounded-tag` | `0.25rem` | badges, celdas mini (ExplicitBadge, índice) | +| `rounded-thumb` | `0.5rem` | carátulas ≤ 36px, logo | +| `rounded-control` | `0.625rem` | inputs, filas, items de menú, botones | +| `rounded-art` | `0.8125rem`| carátulas 42–88px, tarjetas de grid | +| `rounded-art-lg` | `1.125rem` | carátulas ≥ 136px, dropzones | +| `rounded-panel` | `1.125rem` | contenedores, menús, modales, tiles | +| `rounded-panel-lg`| `1.375rem` | hero, spotlight, "mejor resultado" | +| `rounded-full` | (Tailwind) | pastillas, avatares, botones redondos | + +Eliminar `--radius-art-round` (0 usos). Mapeo de lo existente: + +| Hoy | px | Usos | Pasa a | Δ | +| ---------------------------- | -- | ---- | ----------------- | ---- | +| `rounded-sm`, `rounded` | 4 | 2 | `rounded-tag` | 0 | +| `rounded-md` | 6 | 1 | `rounded-thumb` | +2px | +| `rounded-lg` | 8 | 4 | `rounded-thumb` | 0 | +| `rounded-xl` | 12 | 6 | `rounded-control` | −2px | +| `rounded-2xl` | 16 | 8 | `rounded-art-lg` (arte) / `rounded-panel` (contenedor) | +2px | +| `rounded-3xl` | 24 | 1 | `rounded-panel-lg`| −2px | + +#### B.1.2 Tamaños de carátula/arte (`--spacing-cover-*`) + +| Token | Valor | Usos actuales que absorbe (Δ) | +| ---------------- | ------------------------------ | --------------------------------------------------------------- | +| `cover-xs` | `2.25rem` (36) | thumbs de menús `h-9`; sidebar `h-8.5` (+2) | +| `cover-sm` | `2.625rem` (42) | filas de tabla `h-10.5`; cola y "Populares" `h-10` (+2); player móvil `h-11` (−2) | +| `cover-md` | `3rem` (48) | player escritorio `md:h-12` | +| `cover-lg` | `3.5rem` (56) | "Continuar" `h-14`; filas de búsqueda `h-13` (+4); tarjeta playlist de Inicio `h-15.5` (−6) | +| `cover-xl` | `5.5rem` (88) | "Mejor resultado" `h-22` | +| `cover-2xl` | `8.5rem` (136) | spotlight de Inicio `h-34` | +| `cover-hero` | `9rem` (144) | cabeceras de colección, dropzones, avatar de perfil | +| `cover-hero-sm` | `10.25rem` (164) | cabeceras de colección a partir de `sm:` | + +Uso: `size-cover-sm` (Tailwind 4 `size-*` = `width`+`height`). **Nunca** `h-X w-X` en +pareja: siempre `size-*`. + +#### B.1.3 Iconos (`--spacing-icon-*`) y trazo + +| Token | Valor | Absorbe | +| ---------- | ---------- | ---------------------------------------------- | +| `icon-xs` | `0.875rem` | `h-3.5` (14) | +| `icon-sm` | `1rem` | `h-3.75` (15), `h-4` (16), `h-4.25` (17) | +| `icon-md` | `1.125rem` | `h-4.5` (18) | +| `icon-lg` | `1.25rem` | `h-5` (20), `h-6` (24, login móvil) | +| `icon-xl` | `2.25rem` | `h-9`/`h-10` (estados vacíos, dropzone, Me gusta) | + +Trazo: en `layout.css`, fuera de `@theme`: + +```css +@layer base { + svg.lucide { + stroke-width: 1.8; + } +} +@utility stroke-thin { stroke-width: 1.3; } +@utility stroke-regular { stroke-width: 1.8; } +@utility stroke-bold { stroke-width: 2.5; } +``` + +La propiedad CSS `stroke-width` gana al atributo de presentación que pone Lucide, así que +el prop `strokeWidth` deja de tener efecto: **se elimina de todos los usos**. Mapeo: +`1–1.4` → `stroke-thin`, `1.5–2` → (nada, default), `2.5–3` → `stroke-bold`, +`strokeWidth={0}` + `fill` → clase `stroke-0`. Verifica en el navegador (DevTools → +Computed `stroke-width`) que el default aplica antes de migrar en masa. + +#### B.1.4 Anchos (`--container-*`) y layout + +| Token | Valor | Absorbe | +| --------------------- | -------- | --------------------------------------------------------- | +| `max-w-prose-sm` | `26rem` | `max-w-105` (error), `max-w-108` (hero p), `max-w-110`, `max-w-115` (sin resultados) | +| `max-w-hero` | `34rem` | `max-w-135` (hero, seekbar) | +| `max-w-search` | `30rem` | `max-w-120` (buscador TopBar) | +| `max-w-cta` | `20rem` | `max-w-80` (botones login) | + +Ya existen y se mantienen en `theme.css`: `--mf-sidebar-w`, `--mf-queue-w`, `--mf-nav-h`, +`--mf-player-h`, `--mf-safe-b`. Añadir: + +```css +--mf-player-side-w: 16rem; /* w-64 de PlayerBar/PlayerExtras */ +--mf-menu-w: 15rem; /* w-60 de menús contextuales (= MENU_WIDTH 240) */ +--mf-submenu-w: 16rem; /* w-64 (= SUBMENU_WIDTH 256) */ +--mf-range-h: 3.5px; /* barra de progreso y volumen */ +--mf-range-thumb: 11px; +--mf-volume-w: 84px; +``` + +Las constantes TS `MENU_WIDTH`/`SUBMENU_WIDTH` (duplicadas hoy en `TrackContextMenu` y +`AlbumContextMenu`) pasan a `src/lib/config.ts` con un comentario-no: el nombre basta. + +#### B.1.5 Capas (`z-index`) + +En `theme.css` `:root`: `--z-raised: 10; --z-sticky: 20; --z-backdrop: 30; --z-menu: 40; +--z-submenu: 50; --z-modal: 60; --z-grain: 80;`. Uso: `z-(--z-menu)`. Mapeo 1:1 con los +valores actuales (`z-10`,`z-20`,`z-30`,`z-40`,`z-50`,`z-[60]`, `body::after` 80). Nota: +`MobileHeader` y `PlayerDock` usan `z-40` pero son "sticky", no menú → `--z-sticky` +(verificar que el menú de cuenta sigue encima del header: menú 40 > sticky 20). + +#### B.1.6 Tipografía (recetas `@utility` en `layout.css`) + +Escala tipográfica = la de Tailwind (`text-xs`…`text-4xl`), **sin tamaños arbitrarios**. +Añadir en `@theme`: `--tracking-display: -0.02em; --tracking-eyebrow: 0.1em; +--leading-body: 1.65;`. Recetas (el color **no** va en la receta, se añade aparte): + +```css +@utility text-display-1 { @apply font-display text-3xl font-medium tracking-tight sm:text-4xl; } +@utility text-display-2 { @apply font-display text-2xl font-medium tracking-tight; } +@utility text-display-3 { @apply font-display text-xl font-medium tracking-display; } +@utility text-display-4 { @apply font-display text-lg font-medium tracking-tight; } +@utility text-eyebrow { @apply text-xs font-medium tracking-widest uppercase; } +@utility text-body { @apply text-sm leading-body; } +@utility text-count { @apply text-xs tabular-nums; } +``` + +> Si `@apply` con variante `sm:` dentro de `@utility` no compila, usa +> `@media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--text-4xl--line-height); }` +> como hace `page-x`. + +Mapeo de lo existente: + +| Patrón hoy | Receta | +| -------------------------------------------------------------------------------------------- | ------------------------------ | +| `font-display text-3xl … sm:text-4xl` (PageHeader, CollectionHeader, hero Inicio, h1 búsqueda `text-2xl sm:text-3xl font-semibold tracking-[-0.03em]`) | `text-display-1` (Δ búsqueda: +1 paso) | +| `font-display text-2xl` (stats Inicio, título spotlight, "¡Subida!") | `text-display-2` | +| `font-display text-xl … tracking-[-0.02em]` (Modal, EmptyState, +error, "Sin resultados", diálogos de borrado) | `text-display-3` (todos `font-medium`) | +| `font-display text-lg` (SectionHeading, "Mejor resultado", Queue "En cola" `text-base`) | `text-display-4` | +| `text-xs font-medium tracking-widest uppercase` (+ variantes `tracking-[0.14em]`, `[0.16em]`, `tracking-wider`, `text-sm font-semibold`) | `text-eyebrow` | +| `text-sm leading-[1.65] text-fg-2` | `text-body text-fg-2` | +| `text-xs text-muted tabular-nums` | `text-count text-muted` | +| `tracking-[-0.01em]` (MixTile) | quitar | + +#### B.1.7 Recetas de superficie y movimiento (`@utility` en `layout.css`) + +```css +@utility theme-transition { transition: background 0.5s ease-out; } /* 8× transition-[background] duration-500 */ +@utility glass-bar { /* PlayerBar ×2, TabsBar */ + background-image: var(--mf-bar-bg); + border-top: 1px solid var(--mf-hairline); + backdrop-filter: blur(24px); + transition: background 0.5s ease-out; +} +@utility grid-cards { /* MediaGrid por defecto: 150/165 */ + display: grid; + gap: --spacing(3); + grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr)); + @media (width >= 40rem) { + gap: --spacing(4); + grid-template-columns: repeat(auto-fill, minmax(10.3125rem, 1fr)); + } +} +@utility grid-cards-lg { /* /playlists y /u/[id]: 180/210 */ + display: grid; + gap: --spacing(3); + grid-template-columns: repeat(auto-fill, minmax(11.25rem, 1fr)); + @media (width >= 40rem) { + gap: --spacing(4); + grid-template-columns: repeat(auto-fill, minmax(13.125rem, 1fr)); + } +} +@utility grid-tiles { /* mixes de Inicio: 164 */ + display: grid; + gap: --spacing(5); + grid-template-columns: repeat(auto-fill, minmax(10.25rem, 1fr)); +} +@utility grid-wide { /* playlists de Inicio (258) y géneros (268) → 16.5rem, Δ ±6px */ + display: grid; + gap: --spacing(3.5); + grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); +} +``` + +`.glass-panel` ya existe; `Modal` debe usarla en lugar de su `style=` inline duplicado. + +Stagger (sustituye los 5 `style="animation-delay:…"` con 40 **o** 45ms): + +```css +.animate-enter { animation-delay: calc(min(var(--i, 0), 10) * 45ms); } +``` + +Uso: `style="--i:{i}"`. Único `style=` permitido junto con los de B.4. + +#### B.1.8 Colores: limpieza + +- Borrar utilidades sin uso: `--color-accent-dim`, `--color-accent-glow`, + `--color-accent-hair`, `--color-accent-line`, `--color-accent-muted` (0 usos como + clase; `--mf-accent-dim` sí se usa en `::selection`, se mantiene la variable `--mf-`). +- `--color-on-accent` es alias de `--mf-on-art` (igual que `--color-on-art`) → borrar y + usar `on-art`. `--color-hover` = `--mf-surface-2` (igual que `surface-2`) → mantener + **solo** `hover` como semántico de hover y dejar `surface-2` para fondos en reposo. +- `--mf-sidebar-bg` no lo usa ningún componente (Sidebar usa `bg-bg`) → borrar de + `theme.css` y de `buildThemeTokens`. +- Botón: `--mf-btn-bg` = `--mf-surface-2`; `--mf-btn-secondary-bg` solo difiere en modo + claro. Exponer en `@theme` `--color-btn`, `--color-btn-hover`, `--color-btn-glass`, + `--color-btn-glass-hover` y eliminar los `bg-[var(--mf-btn-…)]` de `Button.svelte`. +- Scrollbar: `rgba(255,255,255,.18/.32)` fijo en `layout.css` (roto en claro) → tokens + `--mf-scrollbar`, `--mf-scrollbar-hover` con variante en `:root[data-theme='light']`. +- `MixTile`: `to-black/42`, `bg-ink/76`, `border-on-art/12` → `to-scrim/40`, receta de + `PlayButton` (B.3). + +### B.2 Helpers TypeScript (fuente única) + +| Archivo (nuevo) | API | Sustituye | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| `src/lib/actions/pressable.ts` | `pressable(node, fn)` → añade `role="button"`, `tabindex=0`, click + Enter/Espacio | 7 copias de `onkeydown` Enter/Space (Inicio ×5, Queue, TrackTitleCell) | +| `src/lib/actions/clickOutside.ts` | `clickOutside(node, fn)` | `onDocumentClick` de `AccountMenu` | +| `src/lib/toggle.svelte.ts` | `createToggle(initial=false)` → `{ get open, show(), close(), toggle() }` | `playlists.svelte.ts` y `player/queuePanel.svelte.ts` (idénticos) | +| `src/lib/format.ts` (ampliar) | `plural(n, one, many)` → `"1 canción"`; `fmtCount(n,'canción','canciones')`; **minúscula siempre** | 15 pluralizaciones a mano, con mayúsculas inconsistentes ("Canción"/"canción") | +| `src/lib/navigation.svelte.ts` (ampliar) | `searchHref(q)`; `appNavLinks(user, counts?)` → `{href,label,icon,count?,primary:boolean}[]` | `buildHref` en TopBar y explore; listas de links de Sidebar y TabsBar | +| `src/lib/player/player.svelte.ts` (ampliar) | `isQueueCurrent(items)`, `playAllOrToggle(items)`, `playShuffled(items)`; renombrar `addToQueue` → `playNextItem` | `playAll`/`isCurrentQueue` en álbum, playlist, liked, mixes, spotlight de Inicio (5 copias) | +| `src/lib/player/player.svelte.ts` | `toQueueItem(track)` único (normaliza `duration`/`listensCount` a `number`) | mapeos a mano en `recentlyPlayed.ts` (×2), `liked.svelte.ts` (×2), `liked/+page`, `tracks.ts#targetForQueueItem` y los `Number(…)` dispersos | +| `src/lib/menus.svelte.ts` | `createTrackMenu()` → `{ state, open(e, track), close(), playNext() }` | estado + handler de menú contextual en Inicio, Explorar, Mezcla | + +### B.3 Componentes nuevos (API) + +| Componente (`$lib/components/ui/`) | Props | Sustituye / absorbe | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| `Artwork.svelte` | `src?`, `trackIds?: (string\|number)[]`, `size: 'xs'\|'sm'\|'md'\|'lg'\|'xl'\|'2xl'\|'hero'`, `shape?: 'auto'\|'round'`, `imageSize?: 'small'\|'medium'\|'large'` (auto por `size`), `fallback?: 'music'\|'gradient'\|'none'`, `class?`, `children?` (overlay) | `Cover`, `PlaylistArt`, `MixArt`; radio automático por tamaño (xs→thumb, sm–xl→art/control, 2xl/hero→art-lg). Prioridad: `src` → mosaico si ≥4 `trackIds` → primera carátula → fallback. Un único `onerror`. | +| `Avatar.svelte` | `name`, `src?`, `size: 'sm'\|'md'\|'lg'\|'hero'`, `class?` | iniciales/imagen en TopBar, MobileHeader, Explorar (×2), `ArtistAvatar` | +| `ListRow.svelte` | `href?` \| `onclick?`, `oncontextmenu?`, `active?`, `size?: 'sm'\|'md'\|'lg'`, `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `meta?`, `leading?: Snippet`, `art: Snippet<[string]>` \| artwork props, `trailing?: Snippet` | `SearchResultRow` + 7 filas a mano (ver P7) | +| `MediaIdentity.svelte` | `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `active?`, `art?: Snippet` \| `artwork` props, `titleClass?` | `TrackTitleCell` (renombrado + generalizado) y el bloque de `player/TrackInfo` | +| `TrackList.svelte` | `tracks: TrackLike[]`, `columns?: ('album'\|'added'\|'uploaded'\|'plays')[]`, `index?`, `onPlay(i)`, `rowAction?: {action, icon, label}` \| `rowActionSnippet?`, `oncontextmenu?(e, track)` | `TrackTable`+`TrackRow`+`TrackIndexCell`+`TrackMeta` y las 6 tablas (P8) | +| `ContextMenu.svelte` | `x`, `y`, `openLeft`, `onClose`, `items: {icon,label,onclick}[]`, `playlistAction?: {action: string, fields: Record, label}`, `playlists` | `TrackContextMenu` + `AlbumContextMenu` (90% idénticos) | +| `PlayButton.svelte` | `playing?`, `size: 'sm'\|'md'\|'lg'`, `variant: 'strong'\|'glass'`, `label`, `onclick?`, `as?: 'button'\|'span'` | play de TransportControls, "Mejor resultado", overlay de MixTile | +| `ConfirmDialog.svelte` | `open`, `onClose`, `title`, `description`, `confirmLabel`, `action` (form POST) | 2 modales de borrado con estilos distintos | +| `Slider.svelte` | `value`, `max`, `label`, `oninput`, `variant: 'seek'\|'volume'` | `SeekBar` (click-only) + `VolumeControl` (`` + (serializado con un helper `tokensToCss(tokens)` en `tokens.ts`). +3. Borrar de `theme.css` todos los tokens que genera `buildThemeTokens` (dark y light). + `theme.css` queda solo con neutrales fijos, superficies, texto, layout y z-index. +4. `+layout.svelte`: los dos `div` de fondo con `style=` → `@utility app-backdrop` / + `app-vignette` en `layout.css`. `main` con `padding-bottom: calc(…)` inline → clase + con `--mf-dock-h` que se activa con `data-has-track` en el propio `main`. + +Cierre: con modo claro guardado, recarga dura sin flash; cambio de pista sigue animando +el hue; check+lint. + +--- + +### P3 — `Artwork` y `Avatar` (arte unificado) + +**Objetivo:** un único componente de carátula y uno de avatar. + +Hallazgos: +- `Cover`, `PlaylistArt` y `MixArt` implementan la misma cascada (imagen → mosaico 2×2 → + primera carátula → placeholder). `MixArt` ≡ `PlaylistArt` con + `trackIds = items.map(i => i.trackId)`. +- `AlbumCard` pasa el **id del álbum** como `trackId` a `Cover` solo para poder usar `src` + (hack). +- Avatar "imagen o inicial" reimplementado en `TopBar`, `MobileHeader`, Explorar (mejor + resultado + fila de usuario) y `ArtistAvatar` (con rayado). +- Cada uso decide su propio radio (`rounded-lg`, `rounded-xl`, `rounded-2xl`, + `rounded-control`, `rounded-art-lg`…) y su tamaño (`h-8.5`, `h-9`, `h-10`, `h-10.5`, + `h-11 md:h-12`, `h-13`, `h-14`, `h-15.5`, `h-22`, `h-34`, `h-36 sm:h-41`). + +Tareas: +1. Crear `Artwork.svelte` y `Avatar.svelte` según B.3 usando tokens B.1.1/B.1.2. + `size` decide dimensión **y** radio; `class` solo para layout (`shrink-0`, márgenes). +2. Migrar **todos** los usos (buscar `Cover`, `PlaylistArt`, `MixArt`, `ArtistAvatar`, + `user.picture`, `profilePictureUrl`) aplicando la tabla B.1.2. +3. `ArtistAvatar` → en `u/[id]/+page.svelte` usar `` (el nombre ya lo + muestra la cabecera; el label bajo el avatar era redundante — **Δ visible**, confirmar). +4. Borrar `Cover.svelte`, `PlaylistArt.svelte`, `MixArt.svelte`, `ArtistAvatar.svelte`. + +Cierre: `grep -rn "Cover\b\|PlaylistArt\|MixArt\|ArtistAvatar" src` solo en `Artwork`; +visual en Inicio, Explorar (búsqueda), Sidebar, Cola, player, cabeceras. + +--- + +### P4 — Botones: `Button`, `IconButton`, `PlayButton` + +**Objetivo:** que no quede ningún `