diff --git a/backend/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Musify.Api/Endpoints/AlbumEndpoints.cs index 4add590..7de5d9b 100644 --- a/backend/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -50,15 +50,6 @@ public static class AlbumEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapPost("/youtube/{albumId}", SaveYouTubeAlbum) - .WithName("SaveYouTubeAlbum") - .WithSummary("Save A YouTube Music Album To Your Library With All Of Its Tracks.") - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - group.MapPut("/{albumId}", UpdateAlbum) .WithName("UpdateAlbum") .WithSummary("Update An Album.") @@ -152,19 +143,6 @@ public static class AlbumEndpoints return result.ToCreatedResult(album => $"/albums/{album.Id}"); } - private static async Task SaveYouTubeAlbum( - IMediator mediator, - CurrentUser currentUser, - string albumId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new SaveYouTubeAlbumCommand(currentUser.RequiredId, albumId), - cancellationToken); - - return result.ToCreatedResult(album => $"/albums/{album.Id}"); - } - private static async Task UpdateAlbum( IMediator mediator, CurrentUser currentUser, diff --git a/backend/Musify.Application/Albums/SaveYouTubeAlbum.cs b/backend/Musify.Application/Albums/SaveYouTubeAlbum.cs deleted file mode 100644 index 2e74a0a..0000000 --- a/backend/Musify.Application/Albums/SaveYouTubeAlbum.cs +++ /dev/null @@ -1,112 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Albums.Responses; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Domain.Entities; - -namespace Musify.Application.Albums -{ - public record SaveYouTubeAlbumCommand(long UserId, string AlbumId) - : ICommand>; - - public class SaveYouTubeAlbumCommandHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - YouTubeTrackProvisioner provisioner, - ILogger logger) - : ICommandHandler> - { - public async ValueTask> Handle(SaveYouTubeAlbumCommand request, CancellationToken cancellationToken) - { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == request.UserId, cancellationToken); - if (!userExists) - return Error.NotFound(description: $"User {request.UserId} not found"); - - var alreadySaved = await database.UserAlbums - .AsNoTracking() - .AnyAsync(album => album.OwnerUserId == request.UserId && album.ExternalId == request.AlbumId, cancellationToken); - if (alreadySaved) - { - logger.LogInformation("Album {AlbumId} is already saved for user {UserId}", request.AlbumId, request.UserId); - return Error.Conflict(description: "The album is already in your library."); - } - - var detailResult = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken); - if (detailResult.IsError) - return detailResult.Errors; - - var detail = detailResult.Value; - if (detail.Tracks.Count == 0) - return Error.NotFound(description: "The album has no playable tracks."); - - var title = Truncate(detail.Album.Title, 200); - var album = new UserAlbum - { - OwnerUserId = request.UserId, - ExternalId = request.AlbumId, - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Description = Truncate(detail.Description, 256), - ReleaseYear = detail.Album.ReleaseYear - }; - - await database.UserAlbums.AddAsync(album, cancellationToken); - - var linkedTrackIds = new HashSet(); - foreach (var track in detail.Tracks) - { - var provisionResult = await provisioner.GetOrCreateAsync(track.VideoId, cancellationToken); - if (provisionResult.IsError) - { - logger.LogWarning("Skipped track {VideoId} of album {AlbumId}: {Error}", - track.VideoId, request.AlbumId, provisionResult.FirstError.Description); - continue; - } - - if (!linkedTrackIds.Add(provisionResult.Value.Track.Id)) - continue; - - await database.AlbumHasTracks.AddAsync(new AlbumHasTrack - { - AlbumId = album.Id, - TrackId = provisionResult.Value.Track.Id, - TrackNumber = track.TrackNumber - }, cancellationToken); - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to save YouTube album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); - return Error.Failure(description: $"Failed to save album {request.AlbumId}"); - } - - var albumTracks = database.AlbumHasTracks - .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == album.Id); - - var trackCount = await albumTracks.CountAsync(cancellationToken); - var coverTrackIds = await albumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToListAsync(cancellationToken); - - logger.LogInformation("Saved YouTube album {AlbumId} as {AlbumGuid} with {TrackCount} tracks", - request.AlbumId, album.Id, trackCount); - - return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds); - } - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value[..maxLength]; - } -} diff --git a/backend/Musify.Domain/Entities/UserAlbum.cs b/backend/Musify.Domain/Entities/UserAlbum.cs index b279c7c..4638f62 100644 --- a/backend/Musify.Domain/Entities/UserAlbum.cs +++ b/backend/Musify.Domain/Entities/UserAlbum.cs @@ -9,9 +9,6 @@ namespace Musify.Domain.Entities [Required] public required long OwnerUserId { get; set; } - [MaxLength(64)] - public string? ExternalId { get; set; } - [ForeignKey(nameof(OwnerUserId))] public User Owner { get; set; } } diff --git a/backend/Musify.Infrastructure/Persistence/Database.cs b/backend/Musify.Infrastructure/Persistence/Database.cs index ea0a6be..2fd4af0 100644 --- a/backend/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Musify.Infrastructure/Persistence/Database.cs @@ -111,10 +111,6 @@ namespace Musify.Infrastructure.Persistence modelBuilder.Entity() .ToTable("UserAlbums"); - modelBuilder.Entity() - .HasIndex(album => new { album.OwnerUserId, album.ExternalId }) - .IsUnique(); - modelBuilder.Entity() .HasOne(album => album.Owner) .WithMany() diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.Designer.cs new file mode 100644 index 0000000..6e7acaa --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.Designer.cs @@ -0,0 +1,1178 @@ +// +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("20260721161653_RemoveUserAlbumExternalId")] + partial class RemoveUserAlbumExternalId + { + /// + 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("GeneratedOn") + .HasColumnType("date"); + + 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("integer"); + + 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") + .IsRequired() + .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.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("integer"); + + 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.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.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") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("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") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.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.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/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.cs new file mode 100644 index 0000000..9be1118 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260721161653_RemoveUserAlbumExternalId.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveUserAlbumExternalId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAlbums_OwnerUserId_ExternalId", + table: "UserAlbums"); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "UserAlbums"); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums", + column: "OwnerUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums"); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "UserAlbums", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId_ExternalId", + table: "UserAlbums", + columns: new[] { "OwnerUserId", "ExternalId" }, + unique: true); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 3c9d5d9..120eac9 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -728,15 +728,10 @@ namespace Musify.Infrastructure.Persistence.Migrations { b.HasBaseType("Musify.Domain.Entities.Album"); - b.Property("ExternalId") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - b.Property("OwnerUserId") .HasColumnType("bigint"); - b.HasIndex("OwnerUserId", "ExternalId") - .IsUnique(); + b.HasIndex("OwnerUserId"); b.ToTable("UserAlbums", (string)null); }); diff --git a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs index 07623b9..f9d3cf1 100644 --- a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -162,7 +162,7 @@ namespace Musify.Infrastructure.Services return new YouTubeAlbumDetail( album, - info.Description ?? string.Empty, + StripWikipediaAttribution(info.Description), (int)info.Duration.TotalSeconds, tracks); } @@ -173,6 +173,16 @@ namespace Musify.Infrastructure.Services } } + private static string StripWikipediaAttribution(string? description) + { + if (string.IsNullOrEmpty(description)) + return string.Empty; + + var markerIndex = description.IndexOf("From Wikipedia", StringComparison.OrdinalIgnoreCase); + var cleaned = markerIndex >= 0 ? description[..markerIndex] : description; + return cleaned.TrimEnd(); + } + private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) => new( album.Id, diff --git a/backend/Musify.Worker/AppSettings.Development.json b/backend/Musify.Worker/AppSettings.Development.json index 02d8302..fc1cf37 100644 --- a/backend/Musify.Worker/AppSettings.Development.json +++ b/backend/Musify.Worker/AppSettings.Development.json @@ -16,6 +16,9 @@ "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, + "YouTube": { + "GeographicalLocation": "ES" + }, "InfrastructureStorage": { "Address": "http://localhost:8333", "AccessKey": "admin_access_key", diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 99654b5..4f98533 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -73,23 +73,6 @@ export interface paths { patch?: never; trace?: never; }; - "/albums/youtube/{albumId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Save A YouTube Music Album To Your Library With All Of Its Tracks. */ - post: operations["SaveYouTubeAlbum"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/albums/{albumId}": { parameters: { query?: never; @@ -1042,49 +1025,6 @@ export interface operations { }; }; }; - SaveYouTubeAlbum: { - parameters: { - query?: never; - header?: never; - path: { - albumId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AlbumApplicationResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; UpdateAlbum: { parameters: { query?: never; diff --git a/web-player/src/lib/components/AlbumContextMenu.svelte b/web-player/src/lib/components/AlbumContextMenu.svelte new file mode 100644 index 0000000..3b7edac --- /dev/null +++ b/web-player/src/lib/components/AlbumContextMenu.svelte @@ -0,0 +1,112 @@ + + + + + + + +
+ {#if playlists.length > 0} +
+
+ Añadir álbum a una playlist + +
+ +
+ {:else} +

No tienes playlists todavía.

+ {/if} +
diff --git a/web-player/src/lib/components/Sidebar.svelte b/web-player/src/lib/components/Sidebar.svelte index 95540db..f61dfb0 100644 --- a/web-player/src/lib/components/Sidebar.svelte +++ b/web-player/src/lib/components/Sidebar.svelte @@ -81,7 +81,7 @@ class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex" style="width:var(--mf-sidebar-w)" > - Musify + Musify
{/if} - {user.name} + {user.name}
diff --git a/web-player/src/lib/components/TrackTile.svelte b/web-player/src/lib/components/TrackTile.svelte index 5609145..16e26a9 100644 --- a/web-player/src/lib/components/TrackTile.svelte +++ b/web-player/src/lib/components/TrackTile.svelte @@ -11,6 +11,7 @@ typeLabel?: string; coverSrc?: string | null; coverSize?: 'small' | 'medium' | 'large'; + coverShape?: 'square' | 'round'; hue: number; explicit?: boolean; active: boolean; @@ -28,6 +29,7 @@ typeLabel, coverSrc, coverSize = 'large', + coverShape = 'square', hue, explicit = false, active, @@ -58,7 +60,9 @@ {hue} size={coverSize} alt={title} - class="aspect-square w-full rounded-art shadow-art" + class="aspect-square w-full shadow-art {coverShape === 'round' + ? 'rounded-art-round' + : 'rounded-art'}" > diff --git a/web-player/src/lib/components/player/PlayerBar.svelte b/web-player/src/lib/components/player/PlayerBar.svelte index fb1ac78..c297716 100644 --- a/web-player/src/lib/components/player/PlayerBar.svelte +++ b/web-player/src/lib/components/player/PlayerBar.svelte @@ -3,8 +3,19 @@ import TransportControls from './TransportControls.svelte'; import SeekBar from './SeekBar.svelte'; import VolumeControl from './VolumeControl.svelte'; + import Alert from '$lib/components/ui/Alert.svelte'; + import { player } from '$lib/player/player.svelte'; +{#if player.error} +
+ {player.error} +
+{/if} +
void; } let { @@ -26,7 +27,8 @@ trackCount, trackIds = [], coverSrc, - index = 0 + index = 0, + onContextMenu }: Props = $props(); const meta = $derived( @@ -47,6 +49,7 @@ href={href ?? `/albums/${id}`} class="group animate-enter min-w-0 text-left" style="animation-delay:{index * 45}ms" + oncontextmenu={onContextMenu} > {#if coverSrc} void; } let { @@ -20,7 +21,8 @@ src, alt = '', class: klass = '', - children + children, + oncontextmenu }: Props = $props(); let failed = $state(false); @@ -40,6 +42,8 @@