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 0b7a38d..0000000 Binary files a/backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png and /dev/null differ 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 `