From b1618a39c41235838ecfa7272e88bc4485c0929f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 17 Jul 2026 17:55:32 +0200 Subject: [PATCH] feat(backend): add Artist entity and track ownership Track.Artist was a plain string snapshot with no identity, and UserHasTrack doubled as both "in my library" and "I own this", conflating collection membership with administrative ownership. Introduce Artist/TrackArtist to give artist credits their own identity (needed so YouTube tracks with multiple credited artists, and future renames, can be modeled), and Track.OwnerUserId to represent ownership explicitly (local tracks only, enforced by a check constraint). CreateTrack now provisions the uploader's Artist row and links it via TrackArtist; DeleteTrack authorizes against OwnerUserId instead of any UserHasTrack row. --- .../Musify.Application/Contracts/IDatabase.cs | 4 + .../Musify.Application/Tracks/CreateTrack.cs | 44 +- .../Musify.Application/Tracks/DeleteTrack.cs | 5 +- .../Responses/TrackApplicationResponse.cs | 2 + backend/Musify.Domain/Entities/Artist.cs | 37 + backend/Musify.Domain/Entities/Track.cs | 7 + backend/Musify.Domain/Entities/TrackArtist.cs | 25 + .../Persistence/Database.cs | 43 + ...52_AddArtistsAndTrackOwnership.Designer.cs | 826 ++++++++++++++++++ ...60716214752_AddArtistsAndTrackOwnership.cs | 174 ++++ 10 files changed, 1160 insertions(+), 7 deletions(-) create mode 100644 backend/Musify.Domain/Entities/Artist.cs create mode 100644 backend/Musify.Domain/Entities/TrackArtist.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.Designer.cs create mode 100644 backend/Musify.Infrastructure/Persistence/Migrations/20260716214752_AddArtistsAndTrackOwnership.cs 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"); + } + } +}