diff --git a/.claude/launch.json b/.claude/launch.json index dd00e9b..f641b22 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,14 +4,21 @@ { "name": "musify-web", "runtimeExecutable": "npm", - "runtimeArgs": ["--prefix", "web-player", "run", "dev", "--", "--port", "3000"], - "port": 3000 + "runtimeArgs": ["--prefix", "web-player", "run", "dev"], + "port": 3000, + "autoPort": true }, { "name": "musify-api", "runtimeExecutable": "dotnet", "runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"], "port": 5111 + }, + { + "name": "musify-streaming-gateway", + "runtimeExecutable": "dotnet", + "runtimeArgs": ["run", "--project", "backend/Hosts/Musify.StreamingGateway"], + "port": 8081 } ] } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index 0323ded..1cd4fa9 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Musify.Domain.Entities; +using Musify.Application.Serialization; namespace Musify.Application.Albums.Responses { @@ -17,7 +18,7 @@ namespace Musify.Application.Albums.Responses string Title, string? Description, int? ReleaseYear, - long OwnerUserId, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, int TrackCount, string? SmallImageKeyName, string? MediumImageKeyName, diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index a278e0c..ef100ae 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -41,6 +41,10 @@ namespace Musify.Application.Contracts DbSet ListeningHistories { get; } + DbSet TrackLikes { get; } + + DbSet UserFollows { get; } + Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs new file mode 100644 index 0000000..10f5abb --- /dev/null +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -0,0 +1,45 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using X.PagedList; +using X.PagedList.EF; + +namespace Musify.Application.Likes +{ + public record GetLikedTracksQuery( + long UserId, + int PageNumber, + int PageSize) + : IQuery>>; + + public class GetLikedTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + { + var likesQuery = database.TrackLikes + .AsNoTracking() + .Include(like => ((ExternalTrack)like.Track).TrackArtists) + .ThenInclude(trackArtist => trackArtist.Artist) + .Include(like => ((LocalTrack)like.Track).Owner) + .Where(like => like.UserId == request.UserId); + + var totalCount = await likesQuery.CountAsync(cancellationToken); + + var pagedEntities = await likesQuery + .OrderByDescending(like => like.CreatedAt) + .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedTracks = new StaticPagedList( + pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedTracks); + } + } +} diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs new file mode 100644 index 0000000..3c84f0c --- /dev/null +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -0,0 +1,83 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Likes +{ + public record ToggleTrackLikeCommand( + long UserId, + Guid? TrackId, + TrackSource? Source, + string? ExternalId) + : ICommand>; + + public class ToggleTrackLikeCommandHandler( + IDatabase database, + YouTubeTrackProvisioner provisioner, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) + { + var trackIdResult = await ResolveTrackIdAsync(request, cancellationToken); + if (trackIdResult.IsError) + return trackIdResult.Errors; + + var trackId = trackIdResult.Value; + + var existingLike = await database.TrackLikes + .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == trackId, cancellationToken); + + bool nowLiked; + if (existingLike is not null) + { + database.TrackLikes.Remove(existingLike); + nowLiked = false; + } + else + { + await database.TrackLikes.AddAsync(new TrackLike + { + UserId = request.UserId, + TrackId = trackId + }, cancellationToken); + nowLiked = true; + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {UserId} {Action} track {TrackId}", + request.UserId, nowLiked ? "liked" : "unliked", trackId); + + return nowLiked; + } + + private async ValueTask> ResolveTrackIdAsync(ToggleTrackLikeCommand request, CancellationToken cancellationToken) + { + if (request.TrackId.HasValue) + { + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId.Value, cancellationToken); + if (!trackExists) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); + } + + return request.TrackId.Value; + } + + var provisionResult = await provisioner.GetOrCreateAsync(request.ExternalId!, cancellationToken); + if (provisionResult.IsError) + return provisionResult.Errors; + + return provisionResult.Value.Track.Id; + } + } +} diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index e50a91c..e0598c7 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -17,7 +17,8 @@ namespace Musify.Application.PlayLists long UserId, string Name, string? Description, - Guid? PictureIntentId) + Guid? PictureIntentId, + PlaylistVisibility Visibility = PlaylistVisibility.Private) : ICommand>; public class CreatePlayListCommandHandler( @@ -55,7 +56,8 @@ namespace Musify.Application.PlayLists Name = request.Name, NormalizedName = request.Name.Trim().ToUpperInvariant(), Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(), - Pictures = resolvedPictures.Pictures + Pictures = resolvedPictures.Pictures, + Visibility = request.Visibility }; await database.PlayLists.AddAsync(playList, cancellationToken); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index d5afb0f..71b87bc 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -3,10 +3,11 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.PlayLists.Responses; using Mediator; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists { - public record GetPlayListByIdQuery(Guid Id) + public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) : IQuery>; public class GetPlayListByIdQueryHandler(IDatabase database) @@ -31,6 +32,10 @@ namespace Musify.Application.PlayLists if (entry is null) return Error.NotFound(); + if (entry.PlayList.Visibility == PlaylistVisibility.Private + && entry.PlayList.UserId != request.RequestingUserId) + return Error.NotFound(); + return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index ec1aa93..99a425b 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -14,7 +14,8 @@ namespace Musify.Application.PlayLists long UserId, string? Name, int PageNumber, - int PageSize) + int PageSize, + bool OnlyPublic = false) : IQuery> >; public class GetPlayListsByUserIdQueryHandler(IDatabase database) @@ -26,6 +27,11 @@ namespace Musify.Application.PlayLists .AsNoTracking() .Where(p => p.UserId == request.UserId); + if (request.OnlyPublic) + { + playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public); + } + if (!string.IsNullOrEmpty(request.Name)) { var normalizedName = request.Name.Trim().ToUpperInvariant(); diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 4a1fb93..225c5d2 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,4 +1,5 @@ using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; namespace Musify.Application.PlayLists.Responses { @@ -9,6 +10,7 @@ namespace Musify.Application.PlayLists.Responses string? SmallImageKeyName, string? MediumImageKeyName, string? LargeImageKeyName, + PlaylistVisibility Visibility, DateTime CreatedAt, DateTime UpdatedAt, IReadOnlyList CoverTrackIds) @@ -26,6 +28,7 @@ namespace Musify.Application.PlayLists.Responses playList.Pictures?.SmallName, playList.Pictures?.MediumName, playList.Pictures?.LargeName, + playList.Visibility, playList.CreatedAt, playList.UpdatedAt, coverTrackIds ?? []); diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 1d54615..3d97bdd 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -18,7 +18,8 @@ namespace Musify.Application.PlayLists Guid PlayListId, string? NewName, string? NewDescription, - Guid? NewPictureIntentId) + Guid? NewPictureIntentId, + PlaylistVisibility? NewVisibility = null) : ICommand>; public class UpdatePlayListCommandHandler( @@ -57,6 +58,11 @@ namespace Musify.Application.PlayLists playListEntity.Description = request.NewDescription; } + if (request.NewVisibility.HasValue) + { + playListEntity.Visibility = request.NewVisibility.Value; + } + UploadIntent? pictureIntent = null; string? finalPictureKey = null; diff --git a/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs b/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs new file mode 100644 index 0000000..0dd3526 --- /dev/null +++ b/backend/Core/Musify.Application/Serialization/LongAsStringConverter.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Musify.Application.Serialization; + +// Zitadel-issued user ids routinely exceed Number.MAX_SAFE_INTEGER (2^53-1). +// Serialized as a raw JSON number, the frontend's JSON.parse silently rounds +// them to the nearest representable double, corrupting the id (it becomes a +// different, sometimes valid, user id). Applied via [property: JsonConverter(...)] +// on the specific id-shaped `long`/`long?` response properties (not globally — +// unrelated `long` fields like byte sizes must stay numbers), this serializes +// them as a JSON string instead, which round-trips exactly. +public sealed class LongAsStringConverter : JsonConverter +{ + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.String + ? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) + : reader.GetInt64(); + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); +} + +public sealed class NullableLongAsStringConverter : JsonConverter +{ + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + return reader.TokenType == JsonTokenType.String + ? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture) + : reader.GetInt64(); + } + + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture)); + else + writer.WriteNullValue(); + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs index 6af6f00..09580d3 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; +using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -12,7 +14,7 @@ namespace Musify.Application.Tracks.Responses int ListensCount, DateTime CreatedAt, DateTime UpdatedAt, - long OwnerUserId) + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId) : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) { public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) => diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs new file mode 100644 index 0000000..c4d6541 --- /dev/null +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -0,0 +1,50 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; + +namespace Musify.Application.Users +{ + public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class FollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(FollowUserCommand request, CancellationToken cancellationToken) + { + if (request.FollowerId == request.FollowedId) + return Error.Validation(description: "A user cannot follow themselves."); + + var followedExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.FollowedId, cancellationToken); + if (!followedExists) + { + logger.LogInformation("User {FollowedId} not found", request.FollowedId); + return Error.NotFound(description: $"User {request.FollowedId} not found"); + } + + var alreadyFollowing = await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + if (alreadyFollowing) + return new Success(); + + await database.UserFollows.AddAsync(new UserFollow + { + FollowerId = request.FollowerId, + FollowedId = request.FollowedId + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); + + return new Success(); + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs new file mode 100644 index 0000000..1eefb36 --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -0,0 +1,48 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public record GetListeningStatsQuery(long UserId) : IQuery; + + public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); + + public class GetListeningStatsQueryHandler(IDatabase database) + : IQueryHandler + { + public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) + { + var weekAgo = DateTime.UtcNow.AddDays(-7); + + var thisWeekHistory = database.ListeningHistories + .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); + + var tracksThisWeek = await thisWeekHistory + .Select(l => l.TrackId) + .Distinct() + .CountAsync(cancellationToken); + + var secondsThisWeek = await thisWeekHistory + .Join(database.Tracks, l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds) + .SumAsync(cancellationToken); + + var listenedDates = await database.ListeningHistories + .Where(l => l.UserId == request.UserId) + .Select(l => l.ListenedAt.Date) + .Distinct() + .ToListAsync(cancellationToken); + + var listenedDateSet = listenedDates.ToHashSet(); + var streakDays = 0; + var day = DateTime.UtcNow.Date; + while (listenedDateSet.Contains(day)) + { + streakDays++; + day = day.AddDays(-1); + } + + return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays); + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs new file mode 100644 index 0000000..f697448 --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Serialization; + +namespace Musify.Application.Users +{ + public record GetUserProfileQuery(long UserId) : IQuery>; + + public record UserProfileResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + int FollowersCount, + int FollowingCount); + + public class GetUserProfileQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + { + var user = await database.Users + .AsNoTracking() + .Where(u => u.Id == request.UserId) + .Select(u => new + { + u.Id, + u.Name, + u.FirstName, + u.SecondName, + u.ProfilePictureUrl, + u.CreatedAt + }) + .SingleOrDefaultAsync(cancellationToken); + + if (user is null) + return Error.NotFound(); + + var followersCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowedId == request.UserId, cancellationToken); + + var followingCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); + + return new UserProfileResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + followersCount, + followingCount); + } + } +} diff --git a/backend/Core/Musify.Application/Users/IsFollowingUser.cs b/backend/Core/Musify.Application/Users/IsFollowingUser.cs new file mode 100644 index 0000000..a8e9a2a --- /dev/null +++ b/backend/Core/Musify.Application/Users/IsFollowingUser.cs @@ -0,0 +1,17 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery; + + public class IsFollowingUserQueryHandler(IDatabase database) + : IQueryHandler + { + public ValueTask Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) => + new(database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken)); + } +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index bde3649..9bd442f 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,9 +1,11 @@ -using Musify.Domain.Entities; +using System.Text.Json.Serialization; +using Musify.Application.Serialization; +using Musify.Domain.Entities; namespace Musify.Application.Users.Responses { public record UserApplicationResponse( - long Id, + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, string Name, string? FirstName, string? SecondName, diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs new file mode 100644 index 0000000..b960c64 --- /dev/null +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -0,0 +1,32 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class UnfollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(UnfollowUserCommand request, CancellationToken cancellationToken) + { + var follow = await database.UserFollows + .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + + if (follow is null) + return new Success(); + + database.UserFollows.Remove(follow); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); + + return new Success(); + } + } +} diff --git a/backend/Core/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs index 1922ef8..584fbb3 100644 --- a/backend/Core/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -33,6 +33,9 @@ namespace Musify.Domain.Entities [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] + public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private; + [Required] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/backend/Core/Musify.Domain/Entities/TrackLike.cs b/backend/Core/Musify.Domain/Entities/TrackLike.cs new file mode 100644 index 0000000..41ebb9f --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/TrackLike.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("TrackLikes")] + public class TrackLike + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long UserId { get; set; } + + [Required] + public required Guid TrackId { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId))] + public User User { get; set; } + + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/Entities/UserFollow.cs b/backend/Core/Musify.Domain/Entities/UserFollow.cs new file mode 100644 index 0000000..1cd3322 --- /dev/null +++ b/backend/Core/Musify.Domain/Entities/UserFollow.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("UserFollows")] + public class UserFollow + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long FollowerId { get; set; } + + [Required] + public required long FollowedId { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(FollowerId))] + public User Follower { get; set; } + + [ForeignKey(nameof(FollowedId))] + public User Followed { get; set; } + } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs new file mode 100644 index 0000000..f809e24 --- /dev/null +++ b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs @@ -0,0 +1,8 @@ +namespace Musify.Domain.ValueObjects +{ + public enum PlaylistVisibility + { + Private = 0, + Public = 1 + } +} diff --git a/backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png b/backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png index 323650c..0b7a38d 100644 Binary files a/backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png and b/backend/Core/Musify.Infrastructure/Assets/PlayListDefaultCover.png differ diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 91b3241..607bb59 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using MassTransit; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using AppIDatabase = Musify.Application.Contracts.IDatabase; @@ -88,6 +89,10 @@ namespace Musify.Infrastructure.Persistence .HasIndex(artist => artist.ExternalId) .IsUnique(); + modelBuilder.Entity() + .Property(playList => playList.Visibility) + .HasDefaultValue(PlaylistVisibility.Private); + modelBuilder.Entity() .OwnsOne(playList => playList.Pictures, pictures => { @@ -159,6 +164,26 @@ namespace Musify.Infrastructure.Persistence modelBuilder.Entity() .HasIndex(item => new { item.MixId, item.Position }); + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); + var playListProcessing = modelBuilder.Entity(); playListProcessing.HasKey(state => state.CorrelationId); playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); @@ -206,6 +231,10 @@ namespace Musify.Infrastructure.Persistence public DbSet ListeningHistories => Set(); + public DbSet TrackLikes => Set(); + + public DbSet UserFollows => Set(); + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) { var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.Designer.cs new file mode 100644 index 0000000..2082406 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.Designer.cs @@ -0,0 +1,1245 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260914144913_AddPlayListVisibility")] + partial class AddPlayListVisibility + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + 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("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("ArtistId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("TrackId", "ArtistId"); + + b.HasIndex("ArtistId"); + + b.ToTable("TrackArtist"); + }); + + 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.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("Musify.Domain.Entities.ExternalAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("UserAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + + 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.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("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); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.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("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + 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.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.ExternalAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + 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("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.cs new file mode 100644 index 0000000..8e2bc87 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914144913_AddPlayListVisibility.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPlayListVisibility : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Visibility", + table: "PlayLists", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Visibility", + table: "PlayLists"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.Designer.cs new file mode 100644 index 0000000..40e0fdf --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.Designer.cs @@ -0,0 +1,1289 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260914150045_AddTrackLikes")] + partial class AddTrackLikes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + 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("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("ArtistId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("TrackId", "ArtistId"); + + b.HasIndex("ArtistId"); + + b.ToTable("TrackArtist"); + }); + + 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.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.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("Musify.Domain.Entities.ExternalAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("UserAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + + 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.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("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); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.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("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.ExternalAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + 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("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.cs new file mode 100644 index 0000000..609464e --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914150045_AddTrackLikes.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddTrackLikes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrackLikes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackLikes", x => x.Id); + table.ForeignKey( + name: "FK_TrackLikes_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TrackLikes_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrackLikes_TrackId", + table: "TrackLikes", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackLikes_UserId_TrackId", + table: "TrackLikes", + columns: new[] { "UserId", "TrackId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrackLikes"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.Designer.cs new file mode 100644 index 0000000..9db303d --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.Designer.cs @@ -0,0 +1,1333 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260914151900_AddUserFollows")] + partial class AddUserFollows + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + 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("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.ToTable("Albums"); + + b.UseTptMappingStrategy(); + }); + + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("Artists"); + }); + + 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("Artist") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsExplicit") + .HasColumnType("boolean"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("VideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + + b.UseTptMappingStrategy(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("ArtistId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("TrackId", "ArtistId"); + + b.HasIndex("ArtistId"); + + b.ToTable("TrackArtist"); + }); + + 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.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("Musify.Domain.Entities.ExternalAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasBaseType("Musify.Domain.Entities.Album"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("UserAlbums", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("ExternalTracks", (string)null); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasBaseType("Musify.Domain.Entities.Track"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("LocalTracks", (string)null); + }); + + 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.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("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); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.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("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => + { + b.HasOne("Musify.Domain.Entities.Artist", "Artist") + .WithMany("TrackArtists") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") + .WithMany("TrackArtists") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .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.ExternalAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => + { + b.HasOne("Musify.Domain.Entities.Album", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", null) + .WithOne() + .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + 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("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => + { + b.Navigation("TrackArtists"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.cs new file mode 100644 index 0000000..afbf5f3 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260914151900_AddUserFollows.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserFollows : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserFollows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FollowerId = table.Column(type: "bigint", nullable: false), + FollowedId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserFollows", x => x.Id); + table.ForeignKey( + name: "FK_UserFollows_User_FollowedId", + column: x => x.FollowedId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_UserFollows_User_FollowerId", + column: x => x.FollowerId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserFollows_FollowedId", + table: "UserFollows", + column: "FollowedId"); + + migrationBuilder.CreateIndex( + name: "IX_UserFollows_FollowerId_FollowedId", + table: "UserFollows", + columns: new[] { "FollowerId", "FollowedId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserFollows"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 74000bb..001debd 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -431,6 +431,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Property("UserId") .HasColumnType("bigint"); + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.HasKey("Id"); b.HasIndex("UserId"); @@ -518,6 +523,31 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackArtist"); }); + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("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.Upload", b => { b.Property("Id") @@ -636,6 +666,31 @@ namespace Musify.Infrastructure.Persistence.Migrations 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") @@ -1105,6 +1160,25 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Track"); }); + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => { b.HasOne("Musify.Domain.Entities.User", "User") @@ -1116,6 +1190,25 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => { b.HasOne("Musify.Domain.Entities.Track", "Track") diff --git a/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs index 391875e..3444a7f 100644 --- a/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ b/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -13,8 +13,8 @@ namespace Musify.Infrastructure.Services { public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger logger) : IYouTubeMusicService { - private readonly YouTubeMusicClient client = new(geographicalLocation: configuration.GeographicalLocation); - private readonly SingleFlightCache singleFlight = new(cache); + private readonly YouTubeMusicClient client = new YouTubeMusicClient(geographicalLocation: configuration.GeographicalLocation); + private readonly SingleFlightCache singleFlight = new SingleFlightCache(cache); private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc); @@ -89,7 +89,7 @@ namespace Musify.Infrastructure.Services song.IsExplicit, song.Artists .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id) ? null : artist.Id, + string.IsNullOrEmpty(artist.Id)? null: artist.Id, artist.Name)) .ToList())) .ToList(); @@ -147,7 +147,7 @@ namespace Musify.Infrastructure.Services info.IsEp, info.Artists .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id) ? null : artist.Id, + string.IsNullOrEmpty(artist.Id)? null: artist.Id, artist.Name)) .ToList()); @@ -203,7 +203,7 @@ namespace Musify.Infrastructure.Services { var now = DateTime.UtcNow; if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached) - && cached is not null + && cached != null && cached.ExpiresAtUtc > now) { return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds); @@ -267,7 +267,7 @@ namespace Musify.Infrastructure.Services info.IsExplicit, info.Artists .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id) ? null : artist.Id, + string.IsNullOrEmpty(artist.Id)? null: artist.Id, artist.Name)) .ToList()); } @@ -291,8 +291,7 @@ namespace Musify.Infrastructure.Services var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize) ?? candidates.LastOrDefault(); - return chosen is null - ? string.Empty + return chosen ==null? string.Empty : YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize); } diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs index 2f8416b..45a31b9 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs @@ -18,6 +18,7 @@ public static class AuthenticationExtensions serviceProvider.GetRequiredService>().Value); services.AddMemoryCache(); + services.AddHttpClient(); services.AddScoped(); services.ConfigureOptions(); diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index 02c091c..7f1b7e3 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -2,17 +2,30 @@ using Mediator; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Caching.Memory; using Musify.Application.Users; +using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Security.Claims; +using System.Text.Json.Serialization; namespace Musify.Api.Authentication; public sealed class JwtBearerEventsHandler( IMediator mediator, IMemoryCache cache, + IHttpClientFactory httpClientFactory, + AuthenticationConfiguration authenticationConfiguration, ILogger logger) : JwtBearerEvents { private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); + private sealed record UserInfoResponse( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("preferred_username")] string? PreferredUsername, + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("given_name")] string? GivenName, + [property: JsonPropertyName("family_name")] string? FamilyName, + [property: JsonPropertyName("picture")] string? Picture); + public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) { var principal = tokenValidatedContext.Principal!; @@ -28,14 +41,30 @@ public sealed class JwtBearerEventsHandler( if (cache.TryGetValue(cacheKey, out _)) return; - var username = principal.FindFirstValue(ClaimTypes.Name) + // Access tokens from the IdP are minimal (just sub/scope/aud) — profile attributes live on + // the userinfo endpoint, so ask for them there instead of relying on token claims alone. + var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted); + + // Zitadel also issues short-form JWT claim names ("name", "given_name", "family_name", "email"), + // but the newer JsonWebTokenHandler (default since .NET 8) no longer auto-maps those to the + // legacy ClaimTypes.* URIs the way JwtSecurityTokenHandler used to — so check both forms too. + var username = userInfo?.Name + ?? userInfo?.PreferredUsername + ?? principal.FindFirstValue("name") + ?? principal.FindFirstValue(ClaimTypes.Name) ?? principal.FindFirstValue("preferred_username") + ?? userInfo?.Email + ?? principal.FindFirstValue("email") ?? principal.FindFirstValue(ClaimTypes.Email) ?? rawId!; - var firstName = principal.FindFirstValue(ClaimTypes.GivenName); - var lastName = principal.FindFirstValue(ClaimTypes.Surname); - var profilePictureUrl = principal.FindFirstValue("picture"); + var firstName = userInfo?.GivenName + ?? principal.FindFirstValue("given_name") + ?? principal.FindFirstValue(ClaimTypes.GivenName); + var lastName = userInfo?.FamilyName + ?? principal.FindFirstValue("family_name") + ?? principal.FindFirstValue(ClaimTypes.Surname); + var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); try { @@ -51,6 +80,43 @@ public sealed class JwtBearerEventsHandler( } } + private async Task FetchUserInfoAsync( + TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + { + var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString(); + if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var accessToken = authorizationHeader["Bearer ".Length..].Trim(); + if (accessToken.Length == 0) + return null; + + try + { + var client = httpClientFactory.CreateClient(); + var request = new HttpRequestMessage( + HttpMethod.Get, + $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo") + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } + }; + + var response = await client.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); + return null; + } + + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); + return null; + } + } + public override Task AuthenticationFailed(AuthenticationFailedContext context) { logger.LogWarning(context.Exception, "JWT authentication failed"); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs new file mode 100644 index 0000000..90f8f85 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs @@ -0,0 +1,8 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Api.DataTransferObjects.Likes; + +/// Toggle a like on a track already in the catalog by , or one from an +/// external source by + (provisioning it in the +/// background if needed) — exactly one of the two must be given. +public record ToggleLikeRequest(Guid? TrackId, TrackSource? Source, string? ExternalId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs index 47792c0..bc5910e 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs @@ -1,6 +1,9 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Api.DataTransferObjects.PlayLists; public record CreatePlayListRequest( string Name, string? Description, - Guid? PictureIntentId); + Guid? PictureIntentId, + PlaylistVisibility Visibility = PlaylistVisibility.Private); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index 5ac5700..0ccf8fc 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -1,6 +1,9 @@ +using Musify.Domain.ValueObjects; + namespace Musify.Api.DataTransferObjects.PlayLists; public record UpdatePlayListRequest( string? NewName, string? NewDescription, - Guid? NewPictureIntentId); + Guid? NewPictureIntentId, + PlaylistVisibility? NewVisibility = null); diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs new file mode 100644 index 0000000..c707cd9 --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -0,0 +1,61 @@ +using Mediator; +using Musify.Application.Likes; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Api.Authentication; +using Musify.Api.DataTransferObjects.Likes; +using Musify.Api.Extensions; +using Musify.Api.Filters; + +namespace Musify.Api.Endpoints; + +public static class LikeEndpoints +{ + public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/likes") + .WithTags("Likes") + .RequireAuthorization(); + + group.MapGet("/", GetLikedTracks) + .WithName("GetLikedTracks") + .WithSummary("Get The Current User'S Liked Tracks.") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapPost("/toggle", ToggleTrackLike) + .WithName("ToggleTrackLike") + .WithSummary("Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed.") + .AddEndpointFilter>() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + return app; + } + + private static async Task GetLikedTracks( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 10) + { + var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ToggleTrackLike( + IMediator mediator, + CurrentUser currentUser, + ToggleLikeRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId, request.Source, request.ExternalId), + cancellationToken); + + return result.ToHttpResult(); + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index a5d3c43..3004fbd 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -120,10 +120,11 @@ public static class PlayListEndpoints private static async Task GetPlayListById( IMediator mediator, + CurrentUser currentUser, Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetPlayListByIdQuery(id), cancellationToken); + var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -133,9 +134,10 @@ public static class PlayListEndpoints CancellationToken cancellationToken, string? name, int pageNumber = 1, - int pageSize = 10) + int pageSize = 10, + bool onlyPublic = false) { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, onlyPublic), cancellationToken); return result.ToHttpResult(); } @@ -175,7 +177,7 @@ public static class PlayListEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), + new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), cancellationToken); return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); @@ -189,7 +191,7 @@ public static class PlayListEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId), + new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId, request.NewVisibility), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 00539d0..e2cc12c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,4 +1,5 @@ using Mediator; +using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Users; using Musify.Api.Extensions; using Musify.Api.Filters; @@ -34,6 +35,40 @@ public static class UserEndpoints .Produces>() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/listening-stats", GetListeningStats) + .WithName("GetListeningStats") + .WithSummary("Get A User'S Listening Stats For The Current Week And Streak.") + .Produces(); + + group.MapGet("/{id}/profile", GetUserProfile) + .WithName("GetUserProfile") + .WithSummary("Get A User'S Public Profile, Including Follower Counts.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/is-following", IsFollowing) + .WithName("IsFollowingUser") + .WithSummary("Check Whether The Current User Follows Another User.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapPost("/{id}/follow", FollowUser) + .WithName("FollowUser") + .WithSummary("Follow A User.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .ProducesValidationProblem(); + + group.MapDelete("/{id}/follow", UnfollowUser) + .WithName("UnfollowUser") + .WithSummary("Unfollow A User.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized); + group.MapPost("/", CreateUser) .WithName("CreateUser") .WithSummary("Create A New User.") @@ -73,6 +108,52 @@ public static class UserEndpoints return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); } + private static async Task GetListeningStats( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + } + + private static async Task GetUserProfile( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task IsFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + return await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); + } + + private static async Task FollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UnfollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + private static async Task CreateUser( IMediator mediator, CreateUserRequest request, diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index 24e0bab..a6c75df 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Musify.Api.Authentication; +using Musify.Application.Serialization; namespace Musify.Api.OpenApi; @@ -38,6 +39,24 @@ public sealed class OpenApiOptionsSetup(IOptions op return Task.CompletedTask; } ); + // Properties annotated with [JsonConverter(typeof(LongAsStringConverter))] (id-shaped + // `long` fields — see that converter for why) serialize as a JSON string, but the + // schema generator still reflects the declared CLR `long`/`long?` type as a bare + // int64 number. Reconcile the declared schema so generated clients (openapi-typescript) + // type exactly those properties as `string`, matching what actually goes over the wire — + // scoped to the attribute, not the CLR type, so unrelated `long` fields (byte sizes, …) + // are left alone. + options.AddSchemaTransformer((schema, context, _) => + { + if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter) + { + schema.Type = JsonSchemaType.String; + schema.Format = null; + } + + return Task.CompletedTask; + } ); + options.AddOperationTransformer((operation, context, _) => { var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 18e3e6d..524ed83 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -40,6 +40,7 @@ app.UseAuthorization(); app .MapAlbumEndpoints() .MapConfigEndpoints() + .MapLikeEndpoints() .MapMixEndpoints() .MapPlayListEndpoints() .MapTrackEndpoints() diff --git a/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs new file mode 100644 index 0000000..13fec0c --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Likes; +using Musify.Domain.ValueObjects; + +namespace Musify.Api.Validators.Likes; + +public sealed class ToggleLikeRequestValidator : AbstractValidator +{ + public ToggleLikeRequestValidator() + { + RuleFor(x => x) + .Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId))) + .WithMessage("Provide either trackId, or source and externalId, but not both."); + + When(x => x.Source.HasValue, () => + { + RuleFor(x => x.Source) + .Must(source => source == TrackSource.YouTube) + .WithMessage("Unsupported source."); + + RuleFor(x => x.ExternalId) + .NotEmpty() + .Matches("^[A-Za-z0-9_-]{11}$") + .WithMessage("externalId must be a valid YouTube video id."); + }); + } +} diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs new file mode 100644 index 0000000..9be76bb --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -0,0 +1,55 @@ +using Musify.Application.Likes; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Likes +{ + public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase + { + private GetLikedTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() + { + var owner = TestEntities.User(); + var older = TestEntities.LocalTrack(owner, "Older like"); + var newer = TestEntities.LocalTrack(owner, "Newer like"); + await SeedAsync( + owner, older, newer, + new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) }, + new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); + + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title)); + } + + [Fact] + public async Task Handle_NoLikes_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); + } + + [Fact] + public async Task Handle_OtherUsersLikes_AreExcluded() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs new file mode 100644 index 0000000..a0ba867 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs @@ -0,0 +1,103 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Likes; +using Musify.Application.Services; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Likes +{ + public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase + { + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); + + private ToggleTrackLikeCommandHandler CreateHandler() + { + var provisioner = new YouTubeTrackProvisioner( + Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + + return new ToggleTrackLikeCommandHandler(Database, provisioner, NoOpLogger()); + } + + private static YouTubeSongResult BuildSong(string videoId) => new( + videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, + [new YouTubeArtistRef("artist-id", "Song Artist")]); + + [Fact] + public async Task Handle_LocalTrackNotLiked_LikesIt() + { + var owner = TestEntities.User(); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.True(result.Value); + Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_LocalTrackAlreadyLiked_UnlikesIt() + { + var owner = TestEntities.User(); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.False(result.Value); + Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid(), null, null), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UnknownExternalTrack_ProvisionsAndLikesIt() + { + youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); + + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "new-video"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.True(result.Value); + var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken); + Assert.True(await Database.TrackLikes.AnyAsync(like => like.TrackId == track.Id, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_ExistingExternalTrack_LikesItWithoutReprovisioning() + { + var owner = TestEntities.User(); + var track = TestEntities.ExternalTrack(externalId: "abc123"); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "abc123"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.True(result.Value); + await youTubeMusicService.DidNotReceive().GetSongAsync(Arg.Any(), Arg.Any()); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index ee7035f..c7c080d 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -60,6 +60,34 @@ namespace Musify.Application.Tests.PlayLists await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Private, result.Value.Visibility); + } + + [Fact] + public async Task Handle_PublicVisibility_PersistsIt() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + } + [Fact] public async Task Handle_UserMissing_ReturnsNotFound() { diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 5a68047..45c5483 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Tests.PlayLists public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() { var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); var track = TestEntities.LocalTrack(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); @@ -32,5 +32,54 @@ namespace Musify.Application.Tests.PlayLists Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } + + [Fact] + public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + await SeedAsync(owner, stranger, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 6a3bd17..4e051e5 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -27,6 +27,22 @@ namespace Musify.Application.Tests.PlayLists Assert.Equal("Mine", playList.Name); } + [Fact] + public async Task Handle_OnlyPublic_ReturnsOnlyPublicPlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private)); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, OnlyPublic: true), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Public one", playList.Name); + } + [Fact] public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() { diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index 683ed23..10e1c20 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -62,6 +62,36 @@ namespace Musify.Application.Tests.PlayLists Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } + [Fact] + public async Task Handle_NewVisibility_UpdatesIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); + + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlaylistVisibility.Public); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + } + + [Fact] + public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); + await SeedAsync(owner, playList); + + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); + } + [Fact] public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged() { diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 80a240b..a689c5f 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -126,6 +126,26 @@ namespace Musify.Application.Tests.TestSupport modelBuilder.Entity() .HasIndex(item => new { item.MixId, item.Position }); + + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); } public DbSet Users => Set(); @@ -164,6 +184,10 @@ namespace Musify.Application.Tests.TestSupport public DbSet ListeningHistories => Set(); + public DbSet TrackLikes => Set(); + + public DbSet UserFollows => Set(); + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) { var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 5cbeaa9..4e16da1 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -111,7 +111,8 @@ namespace Musify.Application.Tests.TestSupport string name = "Test Playlist", string? description = null, PlayListPictures? pictures = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) => + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + PlaylistVisibility visibility = PlaylistVisibility.Private) => new() { UserId = userId, @@ -119,7 +120,8 @@ namespace Musify.Application.Tests.TestSupport NormalizedName = name.ToUpperInvariant(), Description = description, Pictures = pictures ?? PlayListPictures(), - LifeCycleStatus = lifeCycleStatus + LifeCycleStatus = lifeCycleStatus, + Visibility = visibility }; public static AlbumPictures AlbumPictures() => new() diff --git a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs new file mode 100644 index 0000000..3659bbf --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs @@ -0,0 +1,61 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class FollowUserCommandHandlerTests : HandlerTestBase + { + private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_ValidTarget_CreatesFollow() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); + + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_AlreadyFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_SelfFollow_ReturnsValidationError() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + + [Fact] + public async Task Handle_TargetMissing_ReturnsNotFound() + { + var follower = TestEntities.User(); + await SeedAsync(follower); + + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs new file mode 100644 index 0000000..bd3a725 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -0,0 +1,87 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase + { + private GetListeningStatsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoHistory_ReturnsZeroes() + { + var user = TestEntities.User(); + await SeedAsync(user); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.SecondsThisWeek); + Assert.Equal(0, result.StreakDays); + } + + [Fact] + public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() + { + var user = TestEntities.User(); + var trackA = TestEntities.LocalTrack(user, "Track A", durationSeconds: 200); + var trackB = TestEntities.LocalTrack(user, "Track B", durationSeconds: 100); + await SeedAsync( + user, trackA, trackB, + TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)), + TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.TracksThisWeek); + Assert.Equal(500, result.SecondsThisWeek); + } + + [Fact] + public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() + { + var user = TestEntities.User(); + var track = TestEntities.LocalTrack(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.StreakDays); + } + + [Fact] + public async Task Handle_GapBeforeToday_StopsStreakAtGap() + { + var user = TestEntities.User(); + var track = TestEntities.LocalTrack(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(1, result.StreakDays); + } + + [Fact] + public async Task Handle_NoListenToday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.LocalTrack(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.StreakDays); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs new file mode 100644 index 0000000..57b734e --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs @@ -0,0 +1,41 @@ +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 GetUserProfileQueryHandlerTests : HandlerTestBase + { + private GetUserProfileQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts() + { + var user = TestEntities.User(1, "user"); + var followerA = TestEntities.User(2, "followerA"); + var followerB = TestEntities.User(3, "followerB"); + var followed = TestEntities.User(4, "followed"); + await SeedAsync( + user, followerA, followerB, followed, + new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.FollowersCount); + Assert.Equal(1, result.Value.FollowingCount); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs new file mode 100644 index 0000000..2d7f9ea --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs @@ -0,0 +1,36 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase + { + private IsFollowingUserQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Following_ReturnsTrue() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task Handle_NotFollowing_ReturnsFalse() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); + + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs new file mode 100644 index 0000000..e853dd8 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase + { + private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_ExistingFollow_RemovesIt() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_NotFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); + + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } + } +} diff --git a/web-player/design_handoff_musify_player/Musify.dc.html b/web-player/design_handoff_musify_player/Musify.dc.html new file mode 100644 index 0000000..6acb0f6 --- /dev/null +++ b/web-player/design_handoff_musify_player/Musify.dc.html @@ -0,0 +1,2801 @@ + + + + + + + + + + + + + + +
+
+
+ +
+ + + + +
+
+
+
+ + + +
+
+ + + +
+
+ +
+ + + + + +
+ +
+ +
+ + + + Cola +
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+ ! +
+
+ No se pudo cargar tu música +
+
+ La conexión se interrumpió. Lo que tengas descargado sigue disponible sin + conexión. +
+
+
+ Reintentar +
+
+ Ver descargas +
+
+
+
+ + + +
+ +
+
+
+

+ {{ heroGreeting }} +

+
+ {{ heroBlurb }} +
+
+
+ + + + Reanudar {{ heroResume }} +
+
+ Ver resumen semanal +
+
+
+
+ +
+
+ {{ s.v }} +
+
+ {{ s.k }} +
+
+
+
+
+
+ + +
+
+

+ Continuar escuchando +

+
+
+ +
+
+
+
+
+
+ {{ c.title }} +
+
+ {{ c.sub }} +
+
+
+ + + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ Playlist destacada +
+
+ {{ spotlight.name }} +
+
+ {{ spotlight.meta }} +
+
+
+
+ {{ spotlight.blurb }} +
+
+
+ + Reproducir +
+
+ Mezclar +
+
+
+
+ +
+
+ {{ t.n }} +
+
+
+ {{ t.title }} +
+
+ {{ t.artist }} +
+
+
+ {{ t.dur }} +
+
+
+
+
+
+ + +
+
+
+

+ Mixes +

+
+ Generados a partir de lo que más repites +
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+ {{ it.title }} +
+
+ {{ it.sub }} +
+
+
+
+
+ + +
+

+ Artistas que sigues +

+
+ +
+
+
+
+
+ {{ a.title }} +
+
+ {{ a.sub }} +
+
+
+
+
+ + +
+
+
+

+ Populares esta semana +

+
+ Lo más escuchado entre quienes comparten tu gusto +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + {{ t.n }} + +
+
+
+
+
+
+ {{ t.title }} +
+
+ {{ t.artist }} +
+
+
+ {{ t.plays }} +
+
+ {{ t.dur }} +
+
+
+
+
+ + +
+
+
+

+ Tus playlists +

+
+ Creadas y guardadas por ti +
+
+
+ Ver todas +
+
+
+ +
+
+
+
+
+
+ {{ p.name }} +
+
+ {{ p.meta }} +
+
+ {{ p.artistsLine }} +
+
+
+ + + +
+
+
+
+
+
+
+ + + +
+

+ Descubrir +

+
+ Rutas de escucha fuera de tu historial. Se renuevan cada 12 horas. +
+
+ +
+
+ {{ g.name }} +
+
+ {{ g.meta }} +
+
+
+
+
+
+
+
+

+ Fuera de tu burbuja +

+
+ Álbumes que tu historial nunca te habría servido +
+
+ +
+
+
+
+ + + +
+
+
+ {{ it.title }} +
+
+ {{ it.sub }} +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ {{ listKind }} +
+

+ {{ listTitle }} +

+
{{ listMeta }}
+
+
+ + Reproducir +
+
+ + + +
+
+
+
+ +
+
#
+
Título
+
Álbum
+
Duración
+
+ +
+
+ +
+
+
+
+
+
+ + {{ t.n }} + +
+
+
+
+
+
+
+ {{ t.title }} +
+
+ {{ t.artist }} +
+
+
+
+ {{ t.album }} +
+
+
+ + + +
+
+ {{ t.dur }} +
+
+
+
+
+
+ + + +
+
+
+

+ Tus playlists +

+
+ 7 playlists · 564 canciones · 38 h 12 min +
+
+
+ Nueva playlist +
+
+
+ +
+
+
+
+
+ + + +
+
+
+ {{ p.name }} +
+
+ {{ p.meta }} +
+
+ {{ p.artistsLine }} +
+
+
+
+
+
+ + + +
+
+ + + + + +
+
+ {{ emptyTitle }} +
+
+ {{ emptyBody }} +
+
+
+ {{ emptyCta }} +
+
+ Volver al inicio +
+
+
+
+ + + +
+
+ Sin resultados para “{{ query }}” +
+
+ Revisa la ortografía o prueba con un término más corto. También puedes buscar + por álbum o por playlist. +
+
+ +
+ {{ s.label }} +
+
+
+
+
+
+
+ + + + + +
+ + +
+
+
+
+
+
+
+
+ {{ curTitle }} +
+
+ {{ curArtist }} +
+
+
+ + + +
+
+ +
+
+
+ + + +
+
+ + + +
+
+ + + + + + + + + + +
+
+ + + +
+
+ + + +
+
+
+ {{ elapsed }} +
+
+
+
+
+ {{ total }} +
+
+ +
+
+ + + +
+
+ + + + +
+
+
+
+
+
+
+ + + + +
+
+
+ + +
+ +
+ + + + {{ n.label }} +
+
+
+
+
+
+ + + diff --git a/web-player/design_handoff_musify_player/README.md b/web-player/design_handoff_musify_player/README.md new file mode 100644 index 0000000..1591004 --- /dev/null +++ b/web-player/design_handoff_musify_player/README.md @@ -0,0 +1,325 @@ +# Handoff: Musify — Web Music Player + +## Overview + +Musify es un reproductor de música web: un player permanente, navegación lateral, y vistas de Home / Descubrir / Playlists / Me gusta / Reciente, más estados de carga, error, vacío y sin resultados. Su rasgo de identidad es que **toda la paleta de acento y los fondos derivan de la canción en reproducción**: al cambiar de pista, el tinte del hero, el glow ambiental, el sidebar, la barra del player, el logo, la barra de progreso y los acentos viran en bloque con una transición de 0.6s. + +## About the Design Files + +Los archivos de este paquete son **referencias de diseño hechas en HTML** — prototipos que muestran aspecto y comportamiento previstos, **no código de producción para copiar tal cual**. La tarea es **recrear estos diseños en el entorno del codebase destino** (React, Vue, SwiftUI, nativo…) siguiendo sus patrones y librerías ya establecidos. Si todavía no hay entorno, elige el framework más apropiado para el proyecto e implementa allí. + +El prototipo está escrito como un componente único con estado local y estilos inline. En producción conviene separarlo en componentes (Sidebar, TopBar, Player, Queue, TrackRow, MediaCard, sections de Home) y mover los tokens a la solución de estilos del proyecto. + +## Fidelity + +**Alta fidelidad (hifi).** Colores, tipografía, espaciado, radios, estados y microinteracciones son definitivos. Se espera una recreación fiel usando las librerías del codebase. Las carátulas son fotografías genéricas de marcador de posición; el contenido musical (artistas, álbumes, canciones, playlists, duraciones, reproducciones) es **mock data** y debe sustituirse por datos reales. + +**Regla de producto:** la UI nunca menciona la procedencia de la música. Sin etiquetas de fuente, proveedor ni "powered by". Para el usuario esto es solo Musify. + +--- + +## Layout global + +Altura fija de viewport, sin scroll de página; solo el área de contenido y la cola scrollean. + +``` +┌──────────┬──────────────────────────────┬──────────┐ +│ Sidebar │ TopBar │ Queue │ +│ 254px ├──────────────────────────────┤ 304px │ +│ │ Main (scroll) │ (258px │ +│ │ │ <1180) │ +└──────────┴──────────────────────────────┴──────────┘ + Player flotante (absolute, left/right 0, bottom 0, padding 0 16px 16px) +``` + +- Raíz: `position:relative; height:100vh; overflow:hidden; background: pageBg; transition: background .6s ease`. +- Dos capas decorativas absolutas y no interactivas sobre el fondo: el **ambient glow** y un scrim inferior `radial-gradient(130% 85% at 50% 118%, rgba(0,0,0,.8), transparent 62%)`. +- Fila principal `display:flex; height:100%`. +- El main tiene `padding: 6px 26px` y `padding-bottom` dinámico: `126px` en desktop, `206px` cuando hay barra de pestañas móvil. + +### Breakpoints + +| Ancho | Comportamiento | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `>= 1180px` | Sidebar visible (254px), cola 304px, player completo | +| `< 1180px` (tablet) | Sidebar oculto → barra de pestañas bajo el player; cola 258px; padding inferior 206px | +| `< 760px` (móvil) | Cola oculta; se ocultan shuffle/repeat/volumen/letra/cola del player (queda carátula + título + transporte + progreso); el bloque de info del player pasa a `width:auto` | + +--- + +## Sistema de color dinámico + +Cada pista lleva un `hue` (0–360) asignado. Todo el color de la app se deriva de ese hue en oklch. Los valores están repartidos por la rueda para que el cambio entre pistas consecutivas sea evidente (paso mínimo ~50°). + +```js +// hue = hue de la pista en reproducción +accent = oklch(0.74 0.145 H) // acción, barra de progreso, corazón activo, EQ +accentTitle = oklch(0.87 0.07 H) // títulos sobre superficie tintada +accentMuted = oklch(0.79 0.035 H) // título de la pista activa en tarjetas +accentSoft = oklch(0.74 0.145 H / .08) // fondo de fila/tarjeta activa +accentLine = oklch(0.74 0.145 H / .2) // borde de superficie acentuada +accentHair = oklch(0.74 0.145 H / .09) // borde de tarjeta activa +accentBtnBg = oklch(0.74 0.145 H / .15) +accentBtnBgHover = oklch(0.74 0.145 H / .24) + +// superficies +pageBg = oklch(0.068 0.008 H) +sidebarBg = linear-gradient(180deg, oklch(0.095 0.012 H), oklch(0.068 0.007 H)) +panelBg = oklch(0.09 0.010 H / .28) // panel de cola +barBg = oklch(0.105 0.012 H / .9) // player y tabs (con backdrop-filter: blur(22px)) +hairline = oklch(0.62 0.030 H / .08) // todos los separadores + +// gradientes: tc(a, l) = oklch(0.34 - l/380 , 0.11 - l/1600 , H / a) +ambient = radial-gradient(52% 38% at 14% 0%, tc(.26,30), transparent 54%), + radial-gradient(46% 34% at 78% 0%, tc(.20,40), transparent 56%) +heroBg = linear-gradient(105deg, tc(.34,42) 0%, tc(.11,70) 28%, tc(.03,88) 50%, rgba(6,6,9,0) 70%) +spotlightBg = linear-gradient(100deg, tc(.28,46) 0%, tc(.10,74) 30%, tc(.03,88) 52%, rgba(6,6,9,0) 72%) +logoGrad = linear-gradient(140deg, oklch(0.78 0.15 H), oklch(0.62 0.13 H+40) 92%) +logoGlow = 0 0 20px oklch(0.74 0.145 H / .3) +``` + +**Importante:** hero y spotlight terminan en `alpha 0` antes del borde derecho (70–72%) y **no llevan borde**, para fundirse con el fondo sin corte visible. + +Los elementos que cambian de color reciben `transition: background .6s ease` (y `box-shadow .6s ease` en el logo): raíz, sidebar, panel de cola, barra del player, tabs. + +### Grises fijos (no dependen del hue) + +| Rol | Hex | +| ------------------------------------------- | ------------------------------------------------------------------------------------ | +| Texto principal (títulos de sección, h1/h2) | `#d3d2d6` – `#d9d8dc` | +| Texto de tarjeta / fila | `#cbcacf` / `#cdccd1` | +| Texto base del documento | `#c9c8cc` | +| Secundario | `#8a8a8e`, `#8f8f93`, `#919195` | +| Terciario | `#7f7f83`, `#7d7d81`, `#848488` | +| Cuaternario / contadores | `#6c6c70`, `#696a6d`, `#646468`, `#616165`, `#5c5c60` | +| Iconos inactivos player | `#747478` | +| Botón primario (fondo) | `#c6c4cb`, hover `#d8d7dc`; texto `#0a0a0d` | +| Superficies neutras | `rgba(255,255,255,.022)` base, `.045` hover; bordes `rgba(255,255,255,.04)` / `.075` | + +Los grises son **neutros a propósito** (sin tinte), para que solo el acento lleve color. + +--- + +## Tipografía + +- Display / títulos: **Sora** 400/500/600/700. +- UI / texto: **Plus Jakarta Sans** 400/500/600/700. +- `-webkit-font-smoothing: antialiased`. + +| Uso | Fuente | Tamaño | Peso | Tracking | +| ------------------------------------------ | ------- | ---------------- | ---- | ------------------------------------ | +| H1 hero | Sora | 36px / 1.1 | 600 | -0.035em | +| H1 vista (Descubrir, Playlists, Tracklist) | Sora | 28–33px | 600 | -0.03 / -0.035em | +| H2 sección | Sora | 17.5px | 600 | -0.025em | +| Título spotlight | Sora | 23px | 600 | -0.03em | +| Wordmark | Sora | 17.5px | 600 | -0.02em | +| Subtítulo de sección | Jakarta | 12px | 400 | — | +| Eyebrow / cabecera de columna | Jakarta | 10.5px uppercase | 600 | 0.12–0.17em | +| Título de tarjeta | Jakarta | 13–13.5px | 600 | -0.01em | +| Fila de canción (título) | Jakarta | 12.5–13px | 500 | — | +| Meta / artista | Jakarta | 11–11.5px | 400 | — | +| Números (duración, índice, reproducciones) | Jakarta | 11–12px | 400 | `font-variant-numeric: tabular-nums` | + +Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` con `min-width:0` en el contenedor flex. Los titulares llevan `text-wrap: pretty`. + +--- + +## Escalas + +**Radios:** 8px (miniaturas pequeñas, chips) · 9–11px (carátulas 36–48px, botones) · 12–13px (filas activas, carátulas de tarjeta) · 14–16px (tarjetas, portadas grandes) · 18px (player, tabs, portada de cabecera) · 22–24px (hero, spotlight) · 50% (avatares, botón de play, artistas) · 999px (chips de sugerencia). + +**Sombras:** carátula de tarjeta `0 14px 32px rgba(0,0,0,.45)` · artista `0 12px 28px rgba(0,0,0,.4)` · portada de cabecera `0 24px 56px rgba(0,0,0,.55)` · player `0 20px 56px rgba(0,0,0,.6)` · botón primario hero `0 10px 28px rgba(0,0,0,.45)`. + +**Gaps de grid:** tarjetas 20px · artistas 18px · playlists 14–22px · filas compactas 6–14px. + +**Anchos mínimos de columna (auto-fill):** mixes/álbumes/descubrir 164px · artistas 132px · playlists (tarjeta ancha) 258px · playlists (índice) 196px · continuar escuchando 268px · populares 330px · géneros 228px. + +--- + +## Pantallas + +### 1. Sidebar (`>= 1180px`) + +`width:254px; padding:22px 12px 0 20px; border-right:1px solid hairline; background: sidebarBg`. + +- **Wordmark**: cuadrado 24px con `logoGrad` + `logoGlow`, gap 11px, texto "Musify". Padding inferior 26px. +- **Nav** (columna, gap 2px). Ítem: `padding:9px 12px; border-radius:10px; font-size:13.5px; weight:500`, icono SVG 17px stroke 1.6, etiqueta flexible, contador a la derecha (11px, `#5c5c60`, tabular). + - Activo: color `#d5d4d8`, fondo `rgba(255,255,255,.06)`. + - Inactivo: color `#8b8b8f`, fondo transparente. Hover: fondo `rgba(255,255,255,.04)`, color `#d5d4d8`. Transición `.16s`. + - Ítems: Inicio · Descubrir · Playlists (7) · Me gusta (312) · Reciente. +- **Separador**: `margin:24px 12px 14px; height:1px; background:rgba(255,255,255,.05)`. +- **Cabecera "TUS PLAYLISTS"** (10.5px uppercase, 0.13em, `#616165`) con un "+" a la derecha que navega al índice de playlists. +- **Lista de playlists** (scroll, gap 1px). Fila: miniatura 34px radio 8px (imagen `opacity:.88`), nombre 12.5px, meta 11px `#6c6c70`. Si esa playlist está sonando, un ecualizador de 3 barras de 2px a la derecha. + +### 2. TopBar + +`padding:16px 26px 12px; gap:14px`. + +- Atrás / adelante: círculos de 30px, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.05)`, icono 15px. Atrás activo (`#8b8b8f`, hover `#dbdadf`), adelante deshabilitado (`#555559`). +- Buscador: `flex:1; max-width:480px; height:38px; radius:11px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.055)`, lupa 15px `#737377`, input 13px transparente. Placeholder: "Canciones, artistas, álbumes o playlists". +- Spacer. +- Botón "Cola": alto 33px, radio 10px, 12.5px/500, icono 15px. Activo: color `#d6d5da`, fondo `rgba(255,255,255,.06)`. Inactivo: `#8b8b8f`, fondo `rgba(255,255,255,.028)`. +- Avatar 33px circular con borde `rgba(255,255,255,.1)`. + +### 3. Home + +Ritmo editorial deliberado — no filas iguales encadenadas: + +**a) Hero** — `border-radius:24px; margin-bottom:42px; background: heroBg`, sin borde, `padding:38px 36px 34px; max-width:620px`. + +- Saludo H1 dinámico por hora y día (ver _Copy dinámico_). +- Blurb 13.5px `#8a8a8e`, `line-height:1.65`, `max-width:440px`. +- Botones: primario "Reproducir" (`#c6c4cb` sobre `#0a0a0d`, 12px 21px, radio 12px, icono play 14px) + secundario "Guardar" (borde `rgba(255,255,255,.11)`), y un meta a la derecha en 12px. +- Tres métricas: valor Sora 26px/600 y etiqueta 11.5px — "Canciones esta semana", "Tiempo de escucha", "Artistas nuevos". + +**b) Continuar escuchando** — grid `auto-fill minmax(268px,1fr)`, gap 10px. Fila-tarjeta: `padding:9px 14px 9px 9px; radius:13px`, carátula 46px radio 9px, título 13px/600, sub 11.5px, y un icono play 15px en `accent` que aparece en hover. La pista en curso usa `accentSoft` de fondo, `accentHair` de borde y `accentMuted` en el título. + +**c) Playlist destacada (spotlight)** — grid de dos columnas `auto-fit minmax(290px,1fr)`, `radius:22px`, `background: spotlightBg`, sin borde ni divisor central. + +- Izquierda: portada 88px radio 14px, eyebrow "PLAYLIST DESTACADA", nombre Sora 23px, meta 12px, blurb 13px/1.7, botón "Reproducir" (`accentBtnBg` + `accentLine` + `accentTitle`, hover `accentBtnBgHover`) y "Mezclar" secundario. +- Derecha: 5 canciones con índice 20px, título 12.5px, artista 11px, duración tabular; fondo `rgba(0,0,0,.26)` que se desvanece a transparente hacia la derecha. + +**d) Mixes** — H2 "Mixes" + sub "Generados a partir de lo que más repites". Grid `minmax(164px,1fr)` gap 20px. Tarjeta cuadrada radio 13px con scrim `linear-gradient(180deg,transparent 45%,rgba(0,0,0,.42))` y botón de play circular 38px abajo a la derecha (`rgba(10,10,13,.76)`, blur 8px, borde `rgba(255,255,255,.12)`) que aparece en hover. + +**e) Artistas que sigues** — grid `minmax(132px,1fr)` gap 18px, retratos circulares, texto centrado, nombre 12.5px/600, oyentes 11px. + +**f) Populares esta semana** — grid de dos columnas `auto-fit minmax(330px,1fr)`, `gap:6px 34px`. Fila: índice/EQ 18px, carátula 38px, título + artista, reproducciones 11px y duración 11.5px. + +**g) Tus playlists** — tarjetas anchas `minmax(258px,1fr)` gap 14px: portada 62px radio 11px, nombre 13.5px/600, nº de canciones 11.5px, artistas principales 11px `#696a6d`, play en hover. Enlace "VER TODAS" en la cabecera. + +### 4. Descubrir + +H1 28px + sub. Mosaico de géneros `minmax(228px,1fr)` gap 14px: tarjetas de 112px, radio 16px, fondo `linear-gradient(140deg, oklch(0.24 0.07 Hgénero), rgba(9,9,12,.95))`, borde `rgba(255,255,255,.05)`, nombre Sora 15.5px, meta 11.5px, y una miniatura de 80px rotada 20° asomando en la esquina inferior derecha (`opacity:.82`). Debajo, "Fuera de tu burbuja" con la misma tarjeta cuadrada de Mixes. + +### 5. Tracklist (Me gusta / Reciente / Playlist / Resultados) + +Cabecera: portada 164px radio 18px + eyebrow (`Colección` / `Historial` / `Playlist` / `Búsqueda`), H1 33px, meta 12.5px, botón "Reproducir" y un botón cuadrado 40px de "aleatorio". + +Tabla: `grid-template-columns: 32px 1fr 1fr 92px; gap:16px`. Cabecera 10.5px uppercase `#616165` con borde inferior `rgba(255,255,255,.05)`. Fila `padding:10px 14px; radius:12px`: + +- Col 1: número o, si es la pista activa, EQ de 3 barras de 2.5px en `accent`. +- Col 2: carátula 40px radio 9px + título 13px/500 + artista 11.5px. +- Col 3: álbum (o `—` si falta). +- Col 4: corazón (14px; visible siempre si está marcado, si no solo en hover) + duración tabular. +- Activa: fondo `accentSoft`, título `accentTitle`. Hover: `rgba(255,255,255,.032)`. + +### 6. Índice de playlists + +H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `minmax(196px,1fr)` gap 22px de tarjetas cuadradas con nombre, nº de canciones y artistas principales. + +### 7. Cola (aside derecho) + +`border-left:1px solid hairline; background: panelBg; padding:20px 16px` + padding inferior dinámico. + +- Cabecera "En cola" (Sora 14.5px) + cerrar "×". +- "REPRODUCIENDO": tarjeta con fondo `accentSoft`, borde `accentLine`, carátula 40px, título en `accentTitle`. +- Cabecera "A CONTINUACIÓN · {playlist}" con `gap:12px`; la etiqueta trunca con elipsis (`flex:1 1 auto; min-width:0; nowrap`) y "Vaciar" es `flex:0 0 auto`. +- Lista scrollable: miniatura 36px, título 12px, artista 11px, duración. + +### 8. Player (siempre visible) + +Barra flotante: `radius:18px; padding:11px 16px; background: barBg; backdrop-filter: blur(22px); border:1px solid hairline; box-shadow:0 20px 56px rgba(0,0,0,.6)`; `pointer-events:none` en el contenedor y `auto` en la barra. + +- **Izquierda (256px):** carátula 48px radio 11px, título 13px/600, artista · álbum 11.5px, corazón 16px (activo en `accent`). +- **Centro (flex:1):** shuffle · anterior · play/pausa · siguiente · repetir, gap 14px. Play: círculo 40px `#c6c4cb` con icono `#0a0a0d`, hover `#d6d5da` (**sin scale**). Shuffle/repeat activos en `accent`, inactivos `#747478`. + Progreso: tiempo transcurrido (10.5px tabular, ancho mín. 32px) · pista `rgba(255,255,255,.09)` de 3.5px con relleno en `accent` · duración. Clic en la pista = seek proporcional. +- **Derecha (256px):** letra, volumen (icono + pista de 84px con relleno `#8e8c98`), y botón de cola. Oculto por completo en móvil. + +### 9. Barra de pestañas (`< 1180px`) + +Bajo el player, `margin-top:10px; padding:7px; radius:18px`, mismo `barBg` y blur. Icono 17px + etiqueta 10px, distribuidos con `space-around`. Muestra los 5 ítems de navegación. + +--- + +## Estados + +- **Loading (skeleton):** bloque de hero de 260px + 2 secciones de 6 tarjetas. Gradiente `linear-gradient(90deg, rgba(255,255,255,.025), .055, .025)` con `background-size:420px 100%` y `animation: shim 1.25s linear infinite` (`-420px 0` → `420px 0`). Se muestra 900ms al arrancar. +- **Error:** cuadro 52px con "!" (`rgba(226,130,130,.08)` / borde `.18` / texto `#e09292`), título 20px, cuerpo 13px, botones "Reintentar" (primario) y "Ver descargas". Copy: "No se pudo cargar tu música" / "La conexión se interrumpió. Lo que tengas descargado sigue disponible sin conexión." Reintentar vuelve al skeleton 800ms. +- **Vacío:** icono 70px radio 19px con degradado tenue, título 20px, cuerpo 13px, CTA primario + "Volver al inicio". Dos variantes de copy: playlists sin crear y "me gusta" sin canciones. +- **Sin resultados:** título `Sin resultados para "{query}"`, cuerpo de ayuda y 4 chips de sugerencia (radio 999px, borde `rgba(255,255,255,.08)`, 12px). +- **Datos incompletos:** si falta el álbum, la columna muestra `—` y el player omite el `· álbum`; si falta el subtítulo de una tarjeta, "Sin información". Toda carátula tiene un degradado de respaldo detrás de la imagen. + +--- + +## Interacciones + +| Elemento | Comportamiento | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Nav / tabs | Cambia de vista y limpia la búsqueda | +| Playlist (sidebar, tarjeta, índice, spotlight) | Abre la vista de playlist con su contexto | +| Tarjeta / fila de canción | Fija la pista, `pos = 0`, `playing = true` | +| Play/pausa | Alterna reproducción (el reloj avanza 1s por segundo) | +| Siguiente / anterior | Cíclicos sobre el array de pistas, resetean la posición | +| Pista de progreso | Clic → `pos = (x / ancho) * duración` | +| Pista de volumen | Clic → `vol` entre 0 y 1 | +| Corazón (fila o player) | Alterna en el mapa `liked` | +| Shuffle / repeat | Alternan, se tiñen de `accent` | +| Botón "Cola" | Abre/cierra el aside | +| Buscar | Filtra por título, artista y álbum; vacío → vista normal; sin coincidencias → estado "sin resultados" | + +**Animación — reglas:** + +- **Nada de elevación en hover.** Se eliminó a propósito: el feedback es solo un cambio de fondo y la aparición del botón de play. +- Hover de fondo/color: `.15–.18s ease`. Play del player: `.14s` solo de color, sin `scale`. +- Cambio de tinte por canción: `.6s ease`. +- Entrada de vista: `rise` (`opacity 0 → 1`, `translateY(12px) → 0`) en `.3s ease`. Cola: `.22s`. +- EQ de pista activa: `eq` `.9s ease-in-out infinite` alternando `scaleY(.25)` y `scaleY(1)`, con desfases de 0.25/0.5s (0.3/0.6s en el sidebar). + +--- + +## Estado necesario + +``` +view 'home' | 'explore' | 'playlists' | 'playlist' | 'liked' | 'recent' +listCtx nombre de la playlist abierta +idx índice de la pista en reproducción +playing boolean +pos segundos transcurridos (intervalo de 1s, vuelve a 0 al llegar a la duración) +vol 0..1 +queueOpen boolean (forzado a false por debajo de 760px) +query texto de búsqueda +liked { [trackId]: true } +shuffle / repeat boolean +hover clave del elemento con hover (o null) +w ancho de ventana (listener de resize) +demoState 'normal' | 'loading' | 'error' | 'empty' (solo para demostrar estados) +``` + +### Datos esperados + +`Track { id, title, artist, album?, duration, cover?, hue, plays? }` · +`Album { title, artist, year, cover? }` · +`Artist { name, listeners, photo? }` · +`Playlist { name, trackCount, topArtists, cover?, isPlaying? }` + +Todos los campos opcionales deben tener degradado o texto de respaldo. El `hue` debería calcularse en backend desde la carátula (color dominante, con saturación mínima garantizada) y venir en el payload; en el prototipo está precalculado por pista. + +--- + +## Copy dinámico + +Saludo del hero según hora y día de la semana: + +- `< 5h` → "¿Sesión de madrugada, {nombre}?" +- `< 13h` → "Buenos días, {nombre}" +- `< 20h` → "Buenas tardes, {nombre}" (miércoles → "Feliz miércoles"; viernes → "Feliz viernes") +- `>= 20h` → "Buenas noches, {nombre}" +- Sábado/domingo → "Que tengas buen finde, {nombre}" (o "¿Madrugada de finde, {nombre}?" antes de las 5h) + +--- + +## Assets + +- **Carátulas y retratos:** marcadores de posición fotográficos servidos por `picsum.photos` con una semilla por elemento, pintados como `background-image` (no ``) para evitar peticiones fallidas mientras se resuelven los datos. Sustituir por las imágenes reales del catálogo. +- **Iconos:** SVG inline, `stroke-width` 1.6–1.8, `stroke-linecap:round`. Reemplazables por el icon set del codebase. +- **Fuentes:** Sora y Plus Jakarta Sans (Google Fonts). +- Sin logotipos de terceros. El wordmark de Musify es tipográfico + el cuadrado con degradado. + +## Files + +- `Musify.dc.html` — prototipo completo (plantilla + lógica + datos de ejemplo). Contiene todas las vistas y estados; el selector `demoState` permite ver loading / error / empty. +- `support.js` — runtime del prototipo. **No portar**: solo hace funcionar el HTML de referencia. +- `screenshots/` — capturas de referencia (viewport ~924px, es decir en modo tablet: sidebar plegado a barra de pestañas inferior): + - `01-home-hero.png` … `04-home-populares-playlists.png` — recorrido vertical de Home + - `05-descubrir.png`, `06-playlists.png`, `07-tracklist-me-gusta.png`, `08-sin-resultados.png` + Todas con "Yankee" sonando, de ahí el tinte ámbar: con otra pista el mismo layout se ve en otro color. diff --git a/web-player/design_handoff_musify_player/screenshots/01-home-hero.png b/web-player/design_handoff_musify_player/screenshots/01-home-hero.png new file mode 100644 index 0000000..deed3a9 Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/01-home-hero.png differ diff --git a/web-player/design_handoff_musify_player/screenshots/02-home-continuar-spotlight.png b/web-player/design_handoff_musify_player/screenshots/02-home-continuar-spotlight.png new file mode 100644 index 0000000..614e61f Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/02-home-continuar-spotlight.png differ diff --git a/web-player/design_handoff_musify_player/screenshots/03-home-mixes-artistas.png b/web-player/design_handoff_musify_player/screenshots/03-home-mixes-artistas.png new file mode 100644 index 0000000..f7a6ecf Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/03-home-mixes-artistas.png differ diff --git a/web-player/design_handoff_musify_player/screenshots/04-home-populares-playlists.png b/web-player/design_handoff_musify_player/screenshots/04-home-populares-playlists.png new file mode 100644 index 0000000..8ad3399 Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/04-home-populares-playlists.png differ diff --git a/web-player/design_handoff_musify_player/screenshots/05-descubrir.png b/web-player/design_handoff_musify_player/screenshots/05-descubrir.png new file mode 100644 index 0000000..a5f9424 Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/05-descubrir.png differ diff --git a/web-player/design_handoff_musify_player/screenshots/06-playlists.png b/web-player/design_handoff_musify_player/screenshots/06-playlists.png new file mode 100644 index 0000000..37e8b8c Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/06-playlists.png differ diff --git a/web-player/design_handoff_musify_player/screenshots/07-tracklist-me-gusta.png b/web-player/design_handoff_musify_player/screenshots/07-tracklist-me-gusta.png new file mode 100644 index 0000000..125a458 Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/07-tracklist-me-gusta.png differ diff --git a/web-player/design_handoff_musify_player/screenshots/08-sin-resultados.png b/web-player/design_handoff_musify_player/screenshots/08-sin-resultados.png new file mode 100644 index 0000000..98ebf7f Binary files /dev/null and b/web-player/design_handoff_musify_player/screenshots/08-sin-resultados.png differ diff --git a/web-player/design_handoff_musify_player/support.js b/web-player/design_handoff_musify_player/support.js new file mode 100644 index 0000000..712aa3b --- /dev/null +++ b/web-player/design_handoff_musify_player/support.js @@ -0,0 +1,1953 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +'use strict'; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => + key in obj + ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) + : (obj[key] = value); + var __publicField = (obj, key, value) => + __defNormalProp(obj, typeof key !== 'symbol' ? key + '' : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error('dc-runtime: window.React is not available yet'); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error('dc-runtime: window.ReactDOM is not available yet'); + return RD; + } + var h = (...args) => getReact().createElement(...args); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector('x-dc'); + if (!dc) return null; + const scriptEl = doc.querySelector('script[data-dc-script]'); + const { props, preview } = parseDataProps(scriptEl?.getAttribute('data-props') ?? null); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || '' : '', + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(''); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, 'text/html'); + const scriptEl = doc.querySelector('script[data-dc-script]'); + const { props, preview } = parseDataProps(scriptEl?.getAttribute('data-props') ?? null); + return { + template, + js: scriptEl ? scriptEl.textContent || '' : '', + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === 'object' ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== '$') rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ''; + try { + p = decodeURIComponent(p); + } catch {} + const base = p.split('/').pop() || 'Root'; + return base.replace(/\.dc\.html$/, '').replace(/\.html?$/, '') || 'Root'; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = 'html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}'; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ''; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || '/').pathname; + } catch {} + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href) + .then((res) => (res.ok ? res.text() : '')) + .then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }) + .catch(() => {}); + } + const dc = doc.querySelector('x-dc'); + const hostEl = doc.createElement('div'); + hostEl.id = 'dc-root'; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement('style'); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...(entry.propOverrides || {}) }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === '(' && expr[expr.length - 1] === ')' && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case '===': + return lv === rv; + case '!==': + return lv !== rv; + case '==': + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === '!') return !resolve(vals, expr.slice(1)); + if (expr === 'true') return true; + if (expr === 'false') return false; + if (expr === 'null') return null; + if (expr === 'undefined') return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if ( + expr.length >= 2 && + (expr[0] === '"' || expr[0] === "'") && + expr[expr.length - 1] === expr[0] + ) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === '(') depth++; + else if (expr[i] === ')') { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === '[' || c === '(') depth++; + else if (c === ']' || c === ')') depth--; + else if (depth === 0 && (c === '=' || c === '!') && expr[i + 1] === '=') { + if (i > 0 && (expr[i - 1] === '=' || expr[i - 1] === '!')) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === '=' ? c + '==' : c + '='; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === '.') { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === '[') { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === '[') depth++; + else if (expr[j] === ']') { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = 'sc-camel-'; + var INLINE_TEXT_TAGS = new Set( + 'a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr'.split( + ' ' + ) + ); + var RAW_WRAP = { + select: 'sc-raw-select', + table: 'sc-raw-table', + tbody: 'sc-raw-tbody', + thead: 'sc-raw-thead', + tfoot: 'sc-raw-tfoot', + tr: 'sc-raw-tr', + td: 'sc-raw-td', + th: 'sc-raw-th', + caption: 'sc-raw-caption' + }; + var RAW_UNWRAP = Object.fromEntries(Object.entries(RAW_WRAP).map(([k, v]) => [v, k])); + var EVENT_MAP = { + onclick: 'onClick', + onchange: 'onChange', + oninput: 'onInput', + onsubmit: 'onSubmit', + onkeydown: 'onKeyDown', + onkeyup: 'onKeyUp', + onkeypress: 'onKeyPress', + onmousedown: 'onMouseDown', + onmouseup: 'onMouseUp', + onmouseenter: 'onMouseEnter', + onmouseleave: 'onMouseLeave', + onfocus: 'onFocus', + onblur: 'onBlur', + ondoubleclick: 'onDoubleClick', + oncontextmenu: 'onContextMenu', + onmousemove: 'onMouseMove', + onmouseover: 'onMouseOver', + onmouseout: 'onMouseOut', + onpointerdown: 'onPointerDown', + onpointerup: 'onPointerUp', + onpointermove: 'onPointerMove', + onpointerenter: 'onPointerEnter', + onpointerleave: 'onPointerLeave', + onpointercancel: 'onPointerCancel', + onpointerover: 'onPointerOver', + onpointerout: 'onPointerOut', + ongotpointercapture: 'onGotPointerCapture', + onlostpointercapture: 'onLostPointerCapture', + ontouchstart: 'onTouchStart', + ontouchend: 'onTouchEnd', + ontouchmove: 'onTouchMove', + ontouchcancel: 'onTouchCancel', + ondragstart: 'onDragStart', + ondragend: 'onDragEnd', + ondragenter: 'onDragEnter', + ondragleave: 'onDragLeave', + ondragover: 'onDragOver', + onanimationstart: 'onAnimationStart', + onanimationend: 'onAnimationEnd', + onanimationiteration: 'onAnimationIteration', + ontransitionend: 'onTransitionEnd' + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp('<(x-import|dc-import)(' + ATTRS + ')/>', 'gi'); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => + sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace(IMPORT_SELF_CLOSE_RE, (_, t, a) => '<' + t + a + '>'); + html = html.replace(/)/gi, '/gi, ''); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace(new RegExp('(])', 'gi'), '$1' + alias); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(';')) { + const i = decl.indexOf(':'); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith('--') ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes('{{')) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => (i & 1 ? (resolve(vals, s) ?? '') : s)).join(''); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === 'sc-name' || name === 'data-dc-tpl') continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === 'hint-size') { + hintSize = value; + continue; + } + if (key.startsWith('style-')) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== 'dom') { + if ( + key.includes('-') && + !(kind === 'x-import' && (key.startsWith('aria-') || key.startsWith('data-'))) + ) + key = kebabToCamel(key); + } else { + if (key === 'class') key = 'className'; + else if (key === 'for') key = 'htmlFor'; + else if (key.startsWith('on')) + key = EVENT_MAP[key] || 'on' + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + 'position', + 'left', + 'right', + 'top', + 'bottom', + 'inset', + 'width', + 'height', + 'z-index', + 'transform' + ]); + function hostPositionStyle(style) { + const all = + typeof style === 'string' + ? cssToObj(style) + : style != null && typeof style === 'object' + ? style + : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement('template'); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute('data-dc-tpl', String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = (vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i)); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === 'deck-stage') return true; + return ( + el.localName === 'x-import' && + (el.getAttribute('component-from-global-scope') || '') === 'deck-stage' + ); + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes] + .map((c) => ({ c, b: walk(c, host) })) + .filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = + !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) + ? n.getAttribute('data-om-slide-id') + : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? '').trim() === '') { + const base = nextSlideId[j] ? 'omid-ws:' + nextSlideId[j] : 'omid-ws:aux'; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ':' + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute('data-om-slide-id'); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push('omid:' + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === 'string' + ? h(getReact().Fragment, { key: k }, out) + : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === 'sc-for') return walkFor(el, host); + if (tag === 'sc-if') return walkIf(el, host); + if (tag === 'x-import') return walkXImport(el, host); + if (tag === 'sc-helmet') return host.helmet(el); + if (tag === 'dc-import') return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || '?') + '\0' + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn('[dc-runtime] ' + (ctx?.__name || 'template') + ': ' + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ''; + if (!txt.includes('{{')) { + if (!txt.trim() && !txt.includes(' ')) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => + h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute('data-dc-editor-on')) { + return h( + 'span', + { key: i, className: 'sc-interp sc-unresolved' }, + '{{ ' + p.trim() + ' }}' + ); + } + warnUnresolved(ctx, '{{ ' + p.trim() + ' }} never resolved \u2014 rendered as empty'); + return null; + } + return h('span', { key: i, className: 'sc-interp sc-missing' }, p.trim()); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === 'boolean') return null; + return h('span', { key: i, className: 'sc-interp' }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute('list') || ''); + const asName = el.getAttribute('as') || 'item'; + const hintN = parseInt(el.getAttribute('hint-placeholder-count') || '0', 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute('list') || ''; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ')' + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute('value') || ''); + const hintRaw = el.getAttribute('hint-placeholder-val'); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v + ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) + : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute('name') || el.getAttribute('component') || ''; + el.removeAttribute('name'); + el.removeAttribute('component'); + const tplId = el.getAttribute('data-dc-tpl'); + const styleRaw = el.getAttribute('style'); + el.removeAttribute('style'); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, 'dc-import', host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === 'dcProps') { + if (v && typeof v === 'object') Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr(el.getAttribute('component-from-global-scope') || ''); + const exportNameGet = compileAttr( + el.getAttribute('component') || el.getAttribute('name') || '' + ); + const fromRaw = + el.getAttribute('from') || + (el.getAttribute('component-from-global-scope') + ? '' + : el.getAttribute('src') || el.getAttribute('import') || ''); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ''; + const kindOf = (u) => (/\.(jsx|tsx)(\?|#|$)/i.test(u) ? 'jsx' : 'js'); + const tplId = el.getAttribute('data-dc-tpl'); + const styleRaw = el.getAttribute('style'); + el.removeAttribute('style'); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, 'x-import', host); + const hasContent = el.children.length > 0 || !!(el.textContent || '').trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes('{{'); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? '' : String(v); + return s.includes('{{') ? '' : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = + !name || urlBindable + ? null + : globalName + ? host.resolveExternalGlobal(url, globalName) + : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap + ? { + key, + className: 'sc-host-x', + 'data-dc-tpl': tplId, + style: hostStyle || { display: 'contents' } + } + : null; + if (!C) { + const error = urlBindable + ? 'x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL' + : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h('div', wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === 'component' || k === 'componentFromGlobalScope' || k === 'from') { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === 'dcProps') { + if (v && typeof v === 'object') Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h('div', wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h('div', wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll('*')) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = ((h2 << 5) + h2 + s.charCodeAt(i)) | 0; + return s.length + '.' + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + 'script style textarea option title select canvas iframe video audio'.split(' ') + ); + var NOT_INLINE_SELECTOR = ':not(' + [...INLINE_TEXT_TAGS].join(',') + ')'; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute('data-dc-tpl'); + const inlineOnly = + el.childNodes.length > 0 && + !NEVER_CONTENT_KEYED.has(realTag) && + el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? '|' + contentKey(el) : ''; + const { propGetters, pseudoClasses } = collectProps(el, 'dom', host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + 'data-dc-tpl': tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === 'style' && typeof v === 'string') v = cssToObj(v); + if ((k === 'value' || k === 'checked') && v === void 0) { + v = k === 'checked' ? false : ''; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(' '); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, 'props'); + __publicField(this, 'state', {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, '__host'); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() {} + componentDidUpdate(_prevProps) {} + componentWillUnmount() {} + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + 'DCLogic', + 'StreamableLogic', + 'React', + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== 'children'); + const bk = Object.keys(b).filter((k) => k !== 'children'); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ name, hintSize, streaming, error }) { + const [w, hgt] = (hintSize || '100%,60px').split(','); + return h( + 'div', + { + className: 'sc-placeholder' + (streaming ? ' sc-streaming' : ''), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error + ? h('div', { className: 'sc-placeholder-error' }, (name ? name + ': ' : '') + error) + : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(','); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, '__name'); + __publicField(this, '__sub'); + __publicField(this, '__needsDidMount', false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, '__streamingNow', false); + __publicField(this, '__htmlStreamingNow', false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, '__failedLogic', null); + __publicField(this, '__failedUserProps', null); + __publicField(this, '__failedVer', -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, '__ctorError', null); + __publicField(this, 'logic'); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + '[dc-runtime] render error in <' + this.__name + '>:', + e, + info?.componentStack || '' + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = + this.__name + ': ' + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic(this.__userProps()); + } + this.logic.__host = this; + if (prevState) this.logic.state = { ...(this.logic.state || {}), ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === 'function' ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if ( + Next === Cur || + (!Next && Cur === StreamableLogic) || + (Next === this.__failedLogic && + r.ver === this.__failedVer && + shallowEqual(this.__userProps(), this.__failedUserProps)) + ) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = + 'sc-host' + + (r.htmlStreaming ? ' sc-streaming-html' : '') + + (r.jsStreaming ? ' sc-streaming-js' : ''); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = + this.props.__hostStyle || hintStyle + ? { ...(hintStyle || {}), ...(this.props.__hostStyle || {}) } + : void 0; + const hostBase = { + className: cls, + style: hostStyle, + 'data-sc-name': this.__name, + 'data-dc-tpl': this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [...chain.slice(chain.indexOf(this.__name)), this.__name].join(' \u2192 '); + return h( + 'div', + { ...hostBase, className: cls + ' sc-has-error' }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: 'circular import: ' + cycle + }) + ); + } + if (this.state.__err) { + return h( + 'div', + { ...hostBase, className: cls + ' sc-has-error' }, + h( + 'div', + { className: 'sc-logic-error', 'data-omelette-chrome': '' }, + this.__name + ': ' + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + 'div', + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...(this.logic.renderVals() || {}) }; + } catch (e) { + console.error(e); + renderErr = + this.__name + + '.renderVals(): ' + + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + 'div', + { ...hostBase, className: cls + (renderErr ? ' sc-has-error' : '') }, + renderErr && + h('div', { className: 'sc-logic-error', 'data-omelette-chrome': '' }, renderErr), + h(AncestorContext.Provider, { value: [...chain, this.__name] }, r.tpl(vals, this)) + ); + } + } + __publicField(StreamableComponent, 'contextType', AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split('#')[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = 'https://unpkg.com/react@18.3.1/umd/react.production.min.js'; + var REACT_SRI = 'sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z'; + var REACT_DOM_URL = 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js'; + var REACT_DOM_SRI = 'sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1'; + var BABEL_URL = 'https://unpkg.com/@babel/standalone@7.29.0/babel.min.js'; + var BABEL_SRI = 'sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y'; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === 'string' && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes('.') && n.includes('-'); + function isRenderableType(g) { + if (typeof g === 'function') return !isElementClass(g); + return typeof g === 'object' && g !== null && typeof g.$$typeof === 'symbol'; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split('.')) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = 'anonymous'; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info('[dc-runtime] x-import: loading', url, '(' + kind + ')'); + const ready = Promise.all([ + kind === 'jsx' ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready + .then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.text(); + }); + }) + .then((src) => { + const code = + kind === 'jsx' + ? window.Babel.transform(src, { + filename: url, + presets: ['react', 'typescript'] + }).code + : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function('React', 'module', 'exports', 'require', code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === 'function') { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + '[dc-runtime] x-import: loaded', + url, + '\u2014 exports:', + Object.keys(module.exports), + 'window globals:', + Object.keys(globals) + ); + onResolved(); + }) + .catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: 'failed to load: ' + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error('[dc-runtime] x-import: FAILED to load', url, '(' + kind + ')', e); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = + (mod && mod[name]) || + (globals && globals[name]) || + (typeof window !== 'undefined' && window[name]) || + (mod && mod.default); + if (typeof C === 'function') return C; + const key = url + '\0' + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(', ') + ')' + ); + console.error( + '[dc-runtime] x-import: module', + url, + 'loaded but has no component named', + JSON.stringify(name), + '\u2014 available exports:', + Object.keys(mod), + 'window globals:', + Object.keys(globals), + '. The module must `module.exports = {' + name + '}` or set `window.' + name + '`.' + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE + ? customElements.get(name) + : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + '[dc-runtime] x-import: global', + JSON.stringify(name), + 'never appeared on window after ' + GLOBAL_POLL_TIMEOUT_MS + 'ms' + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes('.')) return null; + const key = url + '\0global\0' + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + '[dc-runtime] x-import:', + url, + 'loaded but no custom element', + JSON.stringify(name), + 'is registered and window.' + + name + + ' is not a function \u2014 rendering <' + + name + + '> as an unknown element.' + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + '\0' + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return ( + typeof g === 'function' && + typeof HTMLElement !== 'undefined' && + g.prototype instanceof HTMLElement + ); + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = + // layout + '.fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}'; + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = + /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = '#f0eee6'; + var CANVAS_BG_DARK = '#2e2c26'; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = 'light'; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = + ds === 'dark' || ds === 'light' + ? ds + : new URLSearchParams(doc.defaultView?.location.search ?? '').get('theme') === 'dark' + ? 'dark' + : 'light'; + } catch {} + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === 'dark' ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: '__dc_design_mode', mode }, '*'); + } catch {} + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === 'canvas') { + doc.documentElement.setAttribute('data-dc-canvas', ''); + canvasStyleEl = doc.createElement('style'); + canvasStyleEl.setAttribute('data-dc-canvas', ''); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute('data-dc-canvas'); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener('message', (e) => { + const type = e.data && e.data.type; + if (type === '__dc_theme') { + const t = e.data.theme; + if (t === 'light' || t === 'dark') { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== '__dc_probe') return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute('data-dc-atomics') && !mounted.has('__dc-atomics')) { + mounted.add('__dc-atomics'); + const el = doc.createElement('style'); + el.id = '__dc-atomics'; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = (ctx && ctx.__name) || ''; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === 'SCRIPT') { + if (mayBePartial) continue; + const key = 'SCRIPT|' + (child.getAttribute('src') || child.textContent || ''); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement('script'); + for (const { name: an, value } of [...child.attributes]) el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === 'LINK' || tag === 'META') { + if (mayBePartial) continue; + const key = + tag + + '|' + + (child.getAttribute('href') || child.getAttribute('src') || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === 'LINK') { + const rel = (child.getAttribute('rel') || '').toLowerCase().split(/\s+/); + const href = (child.getAttribute('href') || '').trim(); + const res = window.__resources; + const pre = + res && rel.includes('stylesheet') && !rel.includes('alternate') + ? res[href] + : void 0; + const blob = typeof pre === 'string' && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement('style'); + if (child.hasAttribute('disabled')) { + el.setAttribute('media', 'not all'); + } else if (child.getAttribute('media')) { + el.setAttribute('media', child.getAttribute('media')); + } + if (child.getAttribute('title')) + el.setAttribute('title', child.getAttribute('title')); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + '|' + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if ( + (css[i] !== 'u' && css[i] !== 'U') || + css.slice(i, i + 4).toLowerCase() !== 'url(' || + /[a-z0-9_-]/i.test(css[i - 1] ?? '') + ) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ')') { + if (css[j] === '\\') j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ''; + let quote = ''; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === '\\') { + out += c + (css[i + 1] ?? ''); + i++; + continue; + } + if (c === quote) quote = ''; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === '/' && css[i + 1] === '*') { + const end = css.indexOf('*/', i + 2); + i = end === -1 ? css.length : end + 1; + out += ' '; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === '\\') i++; + else if (c === quote) quote = ''; + } else if (c === "'" || c === '"') quote = c; + else if (c === '(') depth++; + else if (c === ')') depth = Math.max(0, depth - 1); + else if (c === ';' && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls + .map((d) => d.trim()) + .filter(Boolean) + .map((d) => (/!\s*important$/i.test(d) ? d : d + ' !important')) + .join(';'); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + '|' + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement('style'); + doc.head.appendChild(el); + } + const cls = 'scp' + (n++).toString(36); + const isPseudoElement = pseudo === 'before' || pseudo === 'after'; + const sel = isPseudoElement ? '.' + cls + '::' + pseudo : '.' + cls + ':' + pseudo; + el.sheet.insertRule( + sel + '{' + (isPseudoElement ? css : importantify(css)) + '}', + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return ( + entries[name] || + (entries[name] = { + html: '', + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }) + ); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = '.'; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager(doc, (name) => registry.get(name).htmlStreaming); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + '/' + encodeURIComponent(name) + '.dc.html'; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === 'string' && pre ? pre : url; + const blob = bundledBlob(target); + (blob + ? blob.text() + : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + 'returned', + res2.status, + '\u2014 the reference renders as an empty placeholder.' + ); + return ''; + } + return res2.text(); + }) + ) + .then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + 'has no block \u2014 not a Design Component.' + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }) + .catch((e) => + console.error('[dc-runtime] sibling fetch for "' + name + '" threw:', url, e) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error('[dc-runtime] template compile FAILED for', name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = (r.jsSeq = (r.jsSeq || 0) + 1); + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== 'function') { + r.logicError = + name + + '.dc.html:
- Musify +
+ + + Musify + -
- {#if user} - - - {#if menuOpen} -
+ {#if user} +
+ {#if user.picture} + + {:else} + + {user.name.charAt(0)} + + {/if} + + + {#if menuOpen} + + (menuOpen = false)} + /> + (menuOpen = false)} + /> + (menuOpen = false)} + /> + {#if accountUrl} + (menuOpen = false)} + /> + {/if} + themeMode.toggle()} + /> +
+ + +
+ {/if} + {:else} + + + {/if} - {:else} - - - - {/if} +
+ +
diff --git a/web-player/src/lib/components/MobileNav.svelte b/web-player/src/lib/components/MobileNav.svelte deleted file mode 100644 index ca717c1..0000000 --- a/web-player/src/lib/components/MobileNav.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - diff --git a/web-player/src/lib/components/SearchBox.svelte b/web-player/src/lib/components/SearchBox.svelte new file mode 100644 index 0000000..551566e --- /dev/null +++ b/web-player/src/lib/components/SearchBox.svelte @@ -0,0 +1,86 @@ + + +
+
+ +
+
diff --git a/web-player/src/lib/components/Sidebar.svelte b/web-player/src/lib/components/Sidebar.svelte index ca7ee29..15cb9c4 100644 --- a/web-player/src/lib/components/Sidebar.svelte +++ b/web-player/src/lib/components/Sidebar.svelte @@ -1,117 +1,129 @@ -{#snippet navLink(link: { href: string; label: string; icon: typeof Home })} - {@const active = isActive(link.href)} - - - {link.label} - -{/snippet} - -{#snippet groupLabel(label: string)} -

- {label} -

-{/snippet} - diff --git a/web-player/src/lib/components/TabsBar.svelte b/web-player/src/lib/components/TabsBar.svelte new file mode 100644 index 0000000..9261466 --- /dev/null +++ b/web-player/src/lib/components/TabsBar.svelte @@ -0,0 +1,50 @@ + + + diff --git a/web-player/src/lib/components/TopBar.svelte b/web-player/src/lib/components/TopBar.svelte index 3778001..ed66c21 100644 --- a/web-player/src/lib/components/TopBar.svelte +++ b/web-player/src/lib/components/TopBar.svelte @@ -1,220 +1,104 @@ -