diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index af0d417..0871a84 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -86,11 +86,23 @@ jobs: git fetch origin master git reset --hard origin/master - - name: Migrate database, generate keys + - name: Generate stream-ticket keys run: | set -euo pipefail cd "$DEPLOY_PATH" - $COMPOSE up -d wait-for-postgres keys-init migrate + $COMPOSE run --rm --no-deps keys-init + + # The migrations are baked into the migrator image, so it is rebuilt on + # every deploy (plain `up` would reuse the stale image and re-apply the + # old migrations). `run --rm` starts wait-for-postgres, blocks until the + # migration finishes and propagates its exit code, so a failed migration + # aborts the deploy before the new API is started against an old schema. + - name: Migrate database + run: | + set -euo pipefail + cd "$DEPLOY_PATH" + $COMPOSE build migrate + $COMPOSE run --rm migrate # Both jobs verify themselves through the public auth./s3. URLs, served # by the shared edge nginx (Infrastructure's edge/), and are idempotent. diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 1b4ff52..4e37204 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -18,7 +18,8 @@ namespace Musify.Application.Tracks string Title, Guid PictureIntentId, Guid AudioIntentId, - IReadOnlyCollection Tags) + IReadOnlyCollection Tags, + bool IsExplicit = false) : ICommand>; public class CreateTrackCommandHandler( @@ -83,6 +84,7 @@ namespace Musify.Application.Tracks NormalizedTitle = request.Title.ToUpperInvariant(), OwnerUserId = user.Id, Owner = user, + IsExplicit = request.IsExplicit, Pictures = new TrackPictures { OriginalName = pictureIntent.ObjectName, diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 02f87de..2748ded 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses DateTime CreatedAt, DateTime UpdatedAt, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, - IReadOnlyCollection Tags) + IReadOnlyCollection Tags, + bool IsExplicit) { public static TrackApplicationResponse FromEntity(Track track, int listensCount) => new( @@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses track.CreatedAt, track.UpdatedAt, track.OwnerUserId, - track.Tags.Select(tag => tag.Tag).ToList()); + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit); } } diff --git a/backend/Core/Musify.Application/Users/FollowVisibility.cs b/backend/Core/Musify.Application/Users/FollowVisibility.cs new file mode 100644 index 0000000..b3c45a8 --- /dev/null +++ b/backend/Core/Musify.Application/Users/FollowVisibility.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public static class FollowVisibility + { + public static async Task CanViewFollowersAsync( + IDatabase database, + long? viewerId, + long userId, + CancellationToken cancellationToken) + { + if (viewerId is not { } viewer) + return false; + + if (viewer == userId) + return true; + + var mutualRows = await database.UserFollows + .AsNoTracking() + .CountAsync(f => + (f.FollowerId == viewer && f.FollowedId == userId) || + (f.FollowerId == userId && f.FollowedId == viewer), + cancellationToken); + + return mutualRows == 2; + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs new file mode 100644 index 0000000..e5b0d9e --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -0,0 +1,46 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Users.Responses; +using X.PagedList.EF; + +namespace Musify.Application.Users +{ + public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowersQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return Error.NotFound(); + + if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) + return Error.Forbidden(); + + var followsQuery = database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId); + + var totalCount = await followsQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; + + var page = await followsQuery + .OrderByDescending(f => f.CreatedAt) + .Select(f => new UserSummaryResponse( + f.Follower.Id, + f.Follower.Name, + f.Follower.FirstName, + f.Follower.SecondName, + f.Follower.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return PaginatedResponse.FromPagedList(page); + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs new file mode 100644 index 0000000..baa9221 --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -0,0 +1,43 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Users.Responses; +using X.PagedList.EF; + +namespace Musify.Application.Users +{ + public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowingQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return Error.NotFound(); + + var followsQuery = database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId); + + var totalCount = await followsQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; + + var page = await followsQuery + .OrderByDescending(f => f.CreatedAt) + .Select(f => new UserSummaryResponse( + f.Followed.Id, + f.Followed.Name, + f.Followed.FirstName, + f.Followed.SecondName, + f.Followed.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return PaginatedResponse.FromPagedList(page); + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index 4d897d2..7066d90 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -7,7 +7,7 @@ using Musify.Application.Serialization; namespace Musify.Application.Users { - public record GetUserProfileQuery(long UserId) : IQuery>; + public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; public record UserProfileResponse( [property: JsonConverter(typeof(LongAsStringConverter))] long Id, @@ -17,7 +17,9 @@ namespace Musify.Application.Users string? ProfilePictureUrl, DateTime CreatedAt, int FollowersCount, - int FollowingCount); + int FollowingCount, + bool IsFollowing, + bool CanViewFollowers); public class GetUserProfileQueryHandler(IDatabase database) : IQueryHandler> @@ -49,6 +51,14 @@ namespace Musify.Application.Users .AsNoTracking() .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); + var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId && + await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken); + + var canViewFollowers = await FollowVisibility.CanViewFollowersAsync( + database, request.ViewerId, request.UserId, cancellationToken); + return new UserProfileResponse( user.Id, user.Name, @@ -57,7 +67,9 @@ namespace Musify.Application.Users user.ProfilePictureUrl, user.CreatedAt, followersCount, - followingCount); + followingCount, + isFollowing, + canViewFollowers); } } } diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index 90955bb..7023c84 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -8,13 +8,13 @@ using Musify.Application.Contracts; namespace Musify.Application.Users { - public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch) - : IQuery>>; + public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) + : IQuery>>; public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler>> { - public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) { var usersQuery = database.Users .AsNoTracking() @@ -27,11 +27,19 @@ namespace Musify.Application.Users } var totalCount = await usersQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; var pagedUsers = await usersQuery - .Select(u => UserApplicationResponse.FromEntity(u)) + .OrderBy(u => u.Name) + .Select(u => new UserSummaryResponse( + u.Id, + u.Name, + u.FirstName, + u.SecondName, + u.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id))) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - return PaginatedResponse.FromPagedList(pagedUsers); + return PaginatedResponse.FromPagedList(pagedUsers); } } } diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs new file mode 100644 index 0000000..6d0af7f --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Musify.Application.Serialization; + +namespace Musify.Application.Users.Responses +{ + public record UserSummaryResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + bool IsFollowedByViewer); +} diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index 439970e..e892b48 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -31,6 +31,9 @@ namespace Musify.Domain.Entities [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] + public bool IsExplicit { get; set; } + [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923203135_AddTrackIsExplicit.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923203135_AddTrackIsExplicit.Designer.cs new file mode 100644 index 0000000..8cea96c --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923203135_AddTrackIsExplicit.Designer.cs @@ -0,0 +1,1167 @@ +// +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("20260923203135_AddTrackIsExplicit")] + partial class AddTrackIsExplicit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .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("IsExplicit") + .HasColumnType("boolean"); + + 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/20260923203135_AddTrackIsExplicit.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923203135_AddTrackIsExplicit.cs new file mode 100644 index 0000000..3cbf32a --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923203135_AddTrackIsExplicit.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddTrackIsExplicit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsExplicit", + table: "Tracks", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsExplicit", + table: "Tracks"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 008ddcc..bf0434b 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -436,6 +436,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("DurationSeconds") .HasColumnType("double precision"); + b.Property("IsExplicit") + .HasColumnType("boolean"); + b.Property("LifeCycleStatus") .HasColumnType("integer"); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs index 6623318..5e95895 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -6,4 +6,5 @@ public record CreateTrackRequest( string Title, Guid PictureIntentId, Guid AudioIntentId, - IReadOnlyCollection Tags); + IReadOnlyCollection Tags, + bool IsExplicit = false); diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 1e8728c..eaa8325 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -185,7 +185,7 @@ public static class TrackEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags), + new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit), cancellationToken); return result.ToCreatedResult(track => $"/tracks/{track.Id}"); diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index c1c97f3..63448eb 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -21,7 +21,7 @@ public static class UserEndpoints group.MapGet("/", GetUsers) .WithName("GetUsers") .WithSummary("Get Paginated Users.") - .Produces>(); + .Produces>(); group.MapGet("/{id}", GetUserById) .WithName("GetUserById") @@ -52,6 +52,19 @@ public static class UserEndpoints .Produces() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/followers", GetFollowers) + .WithName("GetUserFollowers") + .WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.") + .Produces>() + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/following", GetFollowing) + .WithName("GetUserFollowing") + .WithSummary("Get The Users A User Follows.") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/is-following", IsFollowing) .WithName("IsFollowingUser") .WithSummary("Check Whether The Current User Follows Another User.") @@ -88,12 +101,13 @@ public static class UserEndpoints private static async Task GetUsers( IMediator mediator, + CurrentUser currentUser, CancellationToken cancellationToken, string? usernameSearch, int pageNumber = 1, int pageSize = 10) { - var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); + var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -133,10 +147,35 @@ public static class UserEndpoints private static async Task GetUserProfile( IMediator mediator, + CurrentUser currentUser, long id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken); + var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetFollowers( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 20) + { + var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 20) + { + var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs index a708efa..dae34fd 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -17,6 +17,17 @@ namespace Musify.Application.Tests.Shared Assert.Equal("The Owner", response.Artist); Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(3, response.ListensCount); + Assert.False(response.IsExplicit); + } + + [Fact] + public void FromEntity_CopiesExplicitFlag() + { + var track = TestEntities.Track(TestEntities.User(), isExplicit: true); + + var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); + + Assert.True(response.IsExplicit); } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index b908c68..c10efba 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -56,7 +56,8 @@ namespace Musify.Application.Tests.TestSupport TrackPictures? pictures = null, TrackAudio? audio = null, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - IEnumerable? tags = null) + IEnumerable? tags = null, + bool isExplicit = false) { var track = new Track { @@ -64,6 +65,7 @@ namespace Musify.Application.Tests.TestSupport NormalizedTitle = title.ToUpperInvariant(), DurationSeconds = durationSeconds, LifeCycleStatus = lifeCycleStatus, + IsExplicit = isExplicit, OwnerUserId = owner.Id, Owner = owner, Pictures = pictures ?? ProcessedPictures(), diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index de7ebbb..6b9a610 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -58,6 +58,26 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Handle_StoresExplicitFlag(bool isExplicit) + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(isExplicit, result.Value.IsExplicit); + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(isExplicit, stored.IsExplicit); + } + [Fact] public async Task Handle_NoTags_ReturnsValidationError() { diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs new file mode 100644 index 0000000..825e7ff --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs @@ -0,0 +1,95 @@ +using ErrorOr; +using Musify.Application.Shared; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Application.Users.Responses; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase + { + private GetUserFollowersQueryHandler CreateHandler() => new(Database); + + private async Task>> Send(long? viewerId, long userId) => + await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken); + + [Fact] + public async Task Handle_OwnProfile_ReturnsFollowers() + { + var user = TestEntities.User(1, "user"); + var follower = TestEntities.User(2, "follower"); + await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id }); + + var result = await Send(user.Id, user.Id); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal(follower.Id, item.Id); + Assert.False(item.IsFollowedByViewer); + } + + [Fact] + public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var other = TestEntities.User(3, "other"); + await SeedAsync( + viewer, user, other, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }, + new UserFollow { FollowerId = other.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id }); + + var result = await Send(viewer.Id, user.Id); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); + } + + [Fact] + public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + + var result = await Send(viewer.Id, user.Id); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + + var result = await Send(viewer.Id, user.Id); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_Anonymous_ReturnsForbidden() + { + await SeedAsync(TestEntities.User(1, "user")); + + var result = await Send(null, 1); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await Send(1, 404); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs new file mode 100644 index 0000000..c074d00 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs @@ -0,0 +1,75 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase + { + private GetUserFollowingQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Anonymous_ReturnsFollowedUsers() + { + var user = TestEntities.User(1, "user"); + var followedA = TestEntities.User(2, "followedA"); + var followedB = TestEntities.User(3, "followedB"); + await SeedAsync( + user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }); + + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); + } + + [Fact] + public async Task Handle_Viewer_MarksUsersTheViewerFollows() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var followedA = TestEntities.User(3, "followedA"); + var followedB = TestEntities.User(4, "followedB"); + await SeedAsync( + viewer, user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id }); + + var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); + } + + [Fact] + public async Task Handle_Pagination_ReturnsRequestedPage() + { + await SeedAsync( + TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"), + new UserFollow { FollowerId = 1, FollowedId = 2 }, + new UserFollow { FollowerId = 1, FollowedId = 3 }, + new UserFollow { FollowerId = 1, FollowedId = 4 }); + + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Single(result.Value.Items); + Assert.Equal(3, result.Value.TotalItemCount); + Assert.False(result.Value.HasNextPage); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs index 57b734e..45665aa 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs @@ -30,6 +30,46 @@ namespace Musify.Application.Tests.Users Assert.Equal(1, result.Value.FollowingCount); } + [Fact] + public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync( + viewer, user, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + + Assert.True(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } + + [Fact] + public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + + Assert.True(result.Value.IsFollowing); + Assert.False(result.Value.CanViewFollowers); + } + + [Fact] + public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() + { + await SeedAsync(TestEntities.User(1, "user")); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); + + Assert.False(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } + [Fact] public async Task Handle_UserMissing_ReturnsNotFound() { diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs index f60f180..a57cfb4 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; +using Musify.Domain.Entities; using Xunit; namespace Musify.Application.Tests.Users @@ -30,5 +31,18 @@ namespace Musify.Application.Tests.Users var user = Assert.Single(result.Value.Items); Assert.Equal(1, user.Id); } + + [Fact] + public async Task Handle_Viewer_MarksFollowedUsers() + { + await SeedAsync( + TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"), + new UserFollow { FollowerId = 1, FollowedId = 2 }); + + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + } } } diff --git a/web-player/CLAUDE.md b/web-player/CLAUDE.md index 20035f7..444ea62 100644 --- a/web-player/CLAUDE.md +++ b/web-player/CLAUDE.md @@ -42,7 +42,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on). - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome. -- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`. +- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`). ## Styles @@ -55,15 +55,19 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili - **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth. - **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token. - Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`. -- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass. +- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility. + +## App shell + +The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`. ## Motion -Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding. +Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover. Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items. -The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`. +The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`. ## Auth diff --git a/web-player/src/app.html b/web-player/src/app.html index 4d12503..58bf673 100644 --- a/web-player/src/app.html +++ b/web-player/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index b4cb8c8..064a333 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -658,6 +658,40 @@ export interface paths { patch?: never; trace?: never; }; + "/users/{id}/followers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Users Following A User. Visible To The User And To Mutual Followers. */ + get: operations["GetUserFollowers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}/following": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Users A User Follows. */ + get: operations["GetUserFollowing"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/is-following": { parameters: { query?: never; @@ -773,6 +807,8 @@ export interface components { /** Format: uuid */ audioIntentId: string; tags: components["schemas"]["Genre"][]; + /** @default false */ + isExplicit: boolean; }; CreateUserRequest: { /** Format: int64 */ @@ -865,6 +901,19 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; + PaginatedResponseOfUserSummaryResponse: { + items: components["schemas"]["UserSummaryResponse"][]; + /** Format: int32 */ + pageNumber: number | string; + /** Format: int32 */ + pageSize: number | string; + /** Format: int32 */ + pageCount: number | string; + /** Format: int32 */ + totalItemCount: number | string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; PaginatedResponseOfUserApplicationResponse: { items: components["schemas"]["UserApplicationResponse"][]; /** Format: int32 */ @@ -959,6 +1008,7 @@ export interface components { updatedAt: string; ownerUserId: string; tags: components["schemas"]["Genre"][]; + isExplicit: boolean; }; TrackSearchItemResponse: { track: components["schemas"]["TrackApplicationResponse"]; @@ -1039,6 +1089,16 @@ export interface components { followersCount: number | string; /** Format: int32 */ followingCount: number | string; + isFollowing: boolean; + canViewFollowers: boolean; + }; + UserSummaryResponse: { + id: string; + name: string; + firstName: null | string; + secondName: null | string; + profilePictureUrl: null | string; + isFollowedByViewer: boolean; }; }; responses: never; @@ -2428,7 +2488,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PaginatedResponseOfUserApplicationResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"]; }; }; }; @@ -2611,6 +2671,77 @@ export interface operations { }; }; }; + GetUserFollowers: { + parameters: { + query?: { + pageNumber?: number | string; + pageSize?: number | string; + }; + header?: never; + path: { + id: number | string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GetUserFollowing: { + parameters: { + query?: { + pageNumber?: number | string; + pageSize?: number | string; + }; + header?: never; + path: { + id: number | string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; IsFollowingUser: { parameters: { query?: never; diff --git a/web-player/src/lib/components/layout/MobileHeader.svelte b/web-player/src/lib/components/layout/MobileHeader.svelte index 26f9750..98c3062 100644 --- a/web-player/src/lib/components/layout/MobileHeader.svelte +++ b/web-player/src/lib/components/layout/MobileHeader.svelte @@ -44,7 +44,7 @@ {:else} - + {/if} diff --git a/web-player/src/lib/components/layout/Sidebar.svelte b/web-player/src/lib/components/layout/Sidebar.svelte index 562c24d..7649cf9 100644 --- a/web-player/src/lib/components/layout/Sidebar.svelte +++ b/web-player/src/lib/components/layout/Sidebar.svelte @@ -31,7 +31,7 @@