diff --git a/backend/Musify.Application/Contracts/IDatabase.cs b/backend/Musify.Application/Contracts/IDatabase.cs index 33982e3..0c7d0f9 100644 --- a/backend/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Musify.Application/Contracts/IDatabase.cs @@ -11,6 +11,10 @@ namespace Musify.Application.Contracts DbSet Tracks { get; } + DbSet Artists { get; } + + DbSet TrackArtists { get; } + DbSet UserHasTracks { get; } DbSet PlayListHasTracks { get; } diff --git a/backend/Musify.Application/Tracks/CreateTrack.cs b/backend/Musify.Application/Tracks/CreateTrack.cs index 9afafe0..440a8c7 100644 --- a/backend/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Musify.Application/Tracks/CreateTrack.cs @@ -32,9 +32,9 @@ namespace Musify.Application.Tracks { public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) { - var userExists = await database.Users.AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) + var user = await database.Users.AsNoTracking() + .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + if (user is null) { logger.LogWarning("User {UserId} not found", request.UserId); return Error.NotFound(description: $"User {request.UserId} not found"); @@ -59,10 +59,14 @@ namespace Musify.Application.Tracks var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); + var artist = await GetOrCreateUserArtistAsync(user, cancellationToken); + var trackEntity = new Track { Title = request.Title, NormalizedTitle = request.Title.ToUpperInvariant(), + Artist = user.Name, + OwnerUserId = user.Id, OriginalPictureName = pictureIntent.ObjectName, OriginalAudioName = audioIntent.ObjectName, SmallPictureName = trackConfiguration.Routes.PresetSmallPicture, @@ -74,6 +78,13 @@ namespace Musify.Application.Tracks await database.Tracks.AddAsync(trackEntity, cancellationToken); + await database.TrackArtists.AddAsync(new TrackArtist + { + TrackId = trackEntity.Id, + ArtistId = artist.Id, + Position = 0 + }, cancellationToken); + await database.UserHasTracks.AddAsync(new UserHasTrack { UserId = request.UserId, @@ -125,5 +136,32 @@ namespace Musify.Application.Tracks return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); } + + private async Task GetOrCreateUserArtistAsync(User user, CancellationToken cancellationToken) + { + var existing = await database.Artists.SingleOrDefaultAsync(a => a.UserId == user.Id, cancellationToken); + if (existing is not null) + return existing; + + var artist = new Artist + { + Name = user.Name, + NormalizedName = user.NormalizedName, + UserId = user.Id + }; + + await database.Artists.AddAsync(artist, cancellationToken); + + try + { + await database.SaveChangesAsync(cancellationToken); + return artist; + } + catch (DbUpdateException) + { + database.Artists.Remove(artist); + return await database.Artists.SingleAsync(a => a.UserId == user.Id, cancellationToken); + } + } } } diff --git a/backend/Musify.Application/Tracks/DeleteTrack.cs b/backend/Musify.Application/Tracks/DeleteTrack.cs index 7f6fa37..ed24c8d 100644 --- a/backend/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Musify.Application/Tracks/DeleteTrack.cs @@ -28,10 +28,7 @@ namespace Musify.Application.Tracks return Error.NotFound(); } - var isOwner = await database.UserHasTracks - .AsNoTracking() - .AnyAsync(ut => ut.TrackId == request.TrackId && ut.UserId == request.UserId, cancellationToken); - if (!isOwner) + if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); diff --git a/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index da1dc84..a34ff4b 100644 --- a/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -12,6 +12,7 @@ namespace Musify.Application.Tracks.Responses ProcessingStatus AudioStatus, int Duration, int ListensCount, + long? OwnerUserId, DateTime CreatedAt, DateTime UpdatedAt) { @@ -25,6 +26,7 @@ namespace Musify.Application.Tracks.Responses track.AudioTranscodeProcessingStatus, track.Duration, listensCount, + track.OwnerUserId, track.CreatedAt, track.UpdatedAt); } diff --git a/backend/Musify.Domain/Entities/Artist.cs b/backend/Musify.Domain/Entities/Artist.cs new file mode 100644 index 0000000..862d99d --- /dev/null +++ b/backend/Musify.Domain/Entities/Artist.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Musify.Domain.Abstractions; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + public class Artist : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(200)] + public required string Name { get; set; } + + [Required] + [MaxLength(200)] + public required string NormalizedName { get; set; } + + public long? UserId { get; set; } + + [MaxLength(64)] + public string? ExternalId { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId)) ] + public User? User { get; set; } + + public ICollection TrackArtists { get; set; } = new List(); + } +} diff --git a/backend/Musify.Domain/Entities/Track.cs b/backend/Musify.Domain/Entities/Track.cs index 51437d6..0e5b4e7 100644 --- a/backend/Musify.Domain/Entities/Track.cs +++ b/backend/Musify.Domain/Entities/Track.cs @@ -26,6 +26,8 @@ namespace Musify.Domain.Entities [MaxLength(200)] public string? Artist { get; set; } + public long? OwnerUserId { get; set; } + [Required] public TrackSource Source { get; set; } = TrackSource.Local; @@ -73,12 +75,17 @@ namespace Musify.Domain.Entities [Required] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + [ForeignKey(nameof(OwnerUserId))] + public User? Owner { get; set; } + public ICollection UserTracks { get; set; } = new List(); public ICollection PlayListTracks { get; set; } = new List(); public ICollection ListeningHistories { get; set; } = new List(); + public ICollection TrackArtists { get; set; } = new List(); + [NotMapped] [MemberNotNullWhen(true, nameof(SmallPictureName)) ] [MemberNotNullWhen(true, nameof(MediumPictureName)) ] diff --git a/backend/Musify.Domain/Entities/TrackArtist.cs b/backend/Musify.Domain/Entities/TrackArtist.cs new file mode 100644 index 0000000..3bc7f09 --- /dev/null +++ b/backend/Musify.Domain/Entities/TrackArtist.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("TrackArtist")] + public class TrackArtist + { + [Required] + public required Guid TrackId { get; set; } + + [Required] + public required Guid ArtistId { get; set; } + + [Required] + public int Position { get; set; } = 0; + + [ForeignKey(nameof(TrackId)) ] + public Track Track { get; set; } + + [ForeignKey(nameof(ArtistId)) ] + public Artist Artist { get; set; } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Database.cs b/backend/Musify.Infrastructure/Persistence/Database.cs index c7b672a..3ab2357 100644 --- a/backend/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Musify.Infrastructure/Persistence/Database.cs @@ -34,6 +34,45 @@ namespace Musify.Infrastructure.Persistence .HasIndex(track => new { track.Source, track.ExternalId }) .IsUnique(); + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .ToTable(t => t.HasCheckConstraint( + "CK_Tracks_OwnerUserId_Source", + "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)")); + + modelBuilder.Entity() + .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); + + modelBuilder.Entity() + .HasOne(trackArtist => trackArtist.Track) + .WithMany(track => track.TrackArtists) + .HasForeignKey(trackArtist => trackArtist.TrackId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(trackArtist => trackArtist.Artist) + .WithMany(artist => artist.TrackArtists) + .HasForeignKey(trackArtist => trackArtist.ArtistId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(artist => artist.UserId) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(artist => artist.ExternalId) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(artist => artist.NormalizedName) + .IsUnique() + .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + var playListProcessing = modelBuilder.Entity(); playListProcessing.HasKey(state => state.CorrelationId); playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); @@ -44,6 +83,10 @@ namespace Musify.Infrastructure.Persistence public DbSet Tracks => Set(); + public DbSet Artists => Set(); + + public DbSet TrackArtists => Set(); + public DbSet PlayLists => Set(); public DbSet UserHasTracks => Set(); diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.Designer.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.Designer.cs new file mode 100644 index 0000000..d7c4121 --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.Designer.cs @@ -0,0 +1,826 @@ +// +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("20260716214752_AddArtistsAndTrackOwnership")] + partial class AddArtistsAndTrackOwnership + { + /// + 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.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .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.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasFilter("\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + b.HasIndex("UserId") + .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("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + 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("Artist") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadRequested") + .HasColumnType("boolean"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ExternalId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("Tracks", t => + { + t.HasCheckConstraint("CK_Tracks_OwnerUserId_Source", "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + }); + }); + + 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("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.Artist", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + 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.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Owner"); + }); + + 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.Track", "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.Artist", b => + { + b.Navigation("TrackArtists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("TrackArtists"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.cs b/backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.cs new file mode 100644 index 0000000..7dd574d --- /dev/null +++ b/backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.cs @@ -0,0 +1,174 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddArtistsAndTrackOwnership : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Tracks", + type: "bigint", + nullable: true); + + migrationBuilder.CreateTable( + name: "Artists", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + UserId = table.Column(type: "bigint", nullable: true), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Artists", x => x.Id); + table.ForeignKey( + name: "FK_Artists_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "TrackArtist", + columns: table => new + { + TrackId = table.Column(type: "uuid", nullable: false), + ArtistId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackArtist", x => new { x.TrackId, x.ArtistId }); + table.ForeignKey( + name: "FK_TrackArtist_Artists_ArtistId", + column: x => x.ArtistId, + principalTable: "Artists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TrackArtist_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_ExternalId", + table: "Artists", + column: "ExternalId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Artists_NormalizedName", + table: "Artists", + column: "NormalizedName", + unique: true, + filter: "\"ExternalId\" IS NULL AND \"UserId\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Artists_UserId", + table: "Artists", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TrackArtist_ArtistId", + table: "TrackArtist", + column: "ArtistId"); + + migrationBuilder.AddForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql( + """ + UPDATE "Tracks" t SET "OwnerUserId" = ut."UserId" + FROM (SELECT "TrackId", MIN("UserId") AS "UserId" FROM "UserHasTrack" GROUP BY "TrackId") ut + WHERE t."Source" = 0 AND ut."TrackId" = t."Id"; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "Artists" ("Id", "Name", "NormalizedName", "UserId", "ExternalId", "CreatedAt", "UpdatedAt") + SELECT gen_random_uuid(), u."Name", u."NormalizedName", u."Id", NULL, now(), now() + FROM "User" u + WHERE EXISTS (SELECT 1 FROM "Tracks" t WHERE t."OwnerUserId" = u."Id"); + """); + + migrationBuilder.Sql( + """ + INSERT INTO "Artists" ("Id", "Name", "NormalizedName", "UserId", "ExternalId", "CreatedAt", "UpdatedAt") + SELECT gen_random_uuid(), s."Artist", upper(s."Artist"), NULL, NULL, now(), now() + FROM (SELECT DISTINCT "Artist" FROM "Tracks" WHERE "Source" = 1 AND "Artist" IS NOT NULL) s; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "TrackArtist" ("TrackId", "ArtistId", "Position") + SELECT t."Id", a."Id", 0 FROM "Tracks" t + JOIN "Artists" a ON a."UserId" = t."OwnerUserId" + WHERE t."Source" = 0; + """); + + migrationBuilder.Sql( + """ + INSERT INTO "TrackArtist" ("TrackId", "ArtistId", "Position") + SELECT t."Id", a."Id", 0 FROM "Tracks" t + JOIN "Artists" a ON a."UserId" IS NULL AND a."ExternalId" IS NULL AND a."Name" = t."Artist" + WHERE t."Source" = 1 AND t."Artist" IS NOT NULL; + """); + + migrationBuilder.AddCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks", + sql: "(\"OwnerUserId\" IS NOT NULL) = (\"Source\" = 0)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropTable( + name: "TrackArtist"); + + migrationBuilder.DropTable( + name: "Artists"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Tracks_OwnerUserId_Source", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Tracks"); + } + } +}