From fe6b0b7268f9910b32b17ba12443b1750f08cb8e Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 20:47:50 +0200 Subject: [PATCH] feat(backend): save a YouTube album with all of its tracks POST /albums/youtube/{albumId} creates the album in your library and links every song in it, provisioning each one as an external track without queueing any download. The external id is kept on the album so saving it twice returns a conflict instead of a duplicate, and albums can now hold external tracks, not just the ones you uploaded. --- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 22 + .../Albums/AddTrackToAlbum.cs | 15 +- .../Albums/SaveYouTubeAlbum.cs | 101 ++ backend/Musify.Domain/Entities/UserAlbum.cs | 3 + .../Persistence/Database.cs | 4 + ...0184656_AddUserAlbumExternalId.Designer.cs | 1063 +++++++++++++++++ .../20260720184656_AddUserAlbumExternalId.cs | 48 + .../Migrations/DatabaseModelSnapshot.cs | 7 +- 8 files changed, 1256 insertions(+), 7 deletions(-) create mode 100644 backend/Musify.Application/Albums/SaveYouTubeAlbum.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.Designer.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.cs diff --git a/backend/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Musify.Api/Endpoints/AlbumEndpoints.cs index 7de5d9b..4add590 100644 --- a/backend/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -50,6 +50,15 @@ 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.") @@ -143,6 +152,19 @@ 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/AddTrackToAlbum.cs b/backend/Musify.Application/Albums/AddTrackToAlbum.cs index 84975fe..340fe1b 100644 --- a/backend/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Musify.Application/Albums/AddTrackToAlbum.cs @@ -32,16 +32,19 @@ namespace Musify.Application.Albums return Error.Unauthorized(); } - var track = await database.LocalTracks + var trackExists = await database.Tracks .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track is null) + .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + if (!trackExists) { - logger.LogInformation("Local track {TrackId} not found", request.TrackId); - return Error.NotFound(description: "Only tracks you uploaded can be added to an album."); + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); } - if (track.OwnerUserId != request.UserId) + var localTrack = await database.LocalTracks + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (localTrack is not null && localTrack.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); diff --git a/backend/Musify.Application/Albums/SaveYouTubeAlbum.cs b/backend/Musify.Application/Albums/SaveYouTubeAlbum.cs new file mode 100644 index 0000000..2f6b328 --- /dev/null +++ b/backend/Musify.Application/Albums/SaveYouTubeAlbum.cs @@ -0,0 +1,101 @@ +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); + + 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; + } + + 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 trackCount = await database.AlbumHasTracks + .AsNoTracking() + .CountAsync(albumTrack => albumTrack.AlbumId == album.Id, cancellationToken); + + logger.LogInformation("Saved YouTube album {AlbumId} as {AlbumGuid} with {TrackCount} tracks", + request.AlbumId, album.Id, trackCount); + + return AlbumApplicationResponse.FromEntity(album, trackCount); + } + + 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 4638f62..b279c7c 100644 --- a/backend/Musify.Domain/Entities/UserAlbum.cs +++ b/backend/Musify.Domain/Entities/UserAlbum.cs @@ -9,6 +9,9 @@ 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 1defac2..5707040 100644 --- a/backend/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Musify.Infrastructure/Persistence/Database.cs @@ -111,6 +111,10 @@ 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/20260720184656_AddUserAlbumExternalId.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.Designer.cs new file mode 100644 index 0000000..557f179 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.Designer.cs @@ -0,0 +1,1063 @@ +// +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("20260720184656_AddUserAlbumExternalId")] + partial class AddUserAlbumExternalId + { + /// + 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.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("ExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.HasIndex("OwnerUserId", "ExternalId") + .IsUnique(); + + 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.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.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/20260720184656_AddUserAlbumExternalId.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.cs new file mode 100644 index 0000000..1833cf6 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260720184656_AddUserAlbumExternalId.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserAlbumExternalId : Migration + { + /// + protected override void Up(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); + } + + /// + protected override void Down(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"); + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 633a61a..3f5bdc4 100644 --- a/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -642,10 +642,15 @@ 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"); + b.HasIndex("OwnerUserId", "ExternalId") + .IsUnique(); b.ToTable("UserAlbums", (string)null); });