diff --git a/DeepDrftData/Data/Configurations/MixMetadataConfiguration.cs b/DeepDrftData/Data/Configurations/MixMetadataConfiguration.cs new file mode 100644 index 0000000..a315f22 --- /dev/null +++ b/DeepDrftData/Data/Configurations/MixMetadataConfiguration.cs @@ -0,0 +1,48 @@ +using Data.Data.Configurations; +using DeepDrftModels.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DeepDrftData.Data.Configurations; + +public class MixMetadataConfiguration : BaseEntityConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + // Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index. + base.Configure(builder); + + builder.ToTable("mix_metadata"); + + // Map the base audit columns to the snake_case naming the rest of the schema uses. + builder.Property(e => e.Id).HasColumnName("id"); + builder.Property(e => e.CreatedAt).HasColumnName("created_at"); + builder.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + builder.Property(e => e.IsDeleted).HasColumnName("is_deleted"); + + builder.Property(e => e.ReleaseId) + .HasColumnName("release_id"); + + builder.Property(e => e.WaveformEntryKey) + .IsRequired() + .HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs. + .HasColumnName("waveform_entry_key"); + + // 1:1 to the parent release. The unique FK index is the DB-level enforcement of the + // one-satellite-per-release cardinality. Cascade on delete: removing the release removes its + // medium satellite (unlike Track's SetNull — a satellite has no meaning without its release). + builder.HasOne(e => e.Release) + .WithOne(r => r.MixMetadata) + .HasForeignKey(e => e.ReleaseId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => e.ReleaseId) + .IsUnique() + .HasDatabaseName("IX_mix_metadata_release_id"); + + // Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already + // calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses + // "IX_mix_metadata_is_deleted" regardless of auto-naming conventions. + builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_mix_metadata_is_deleted"); + } +} diff --git a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs index b14893c..a9eb1c0 100644 --- a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs +++ b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs @@ -42,6 +42,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration .HasMaxLength(500) .HasColumnName("image_path"); + // ReleaseType is meaningful ONLY when Medium == Cut. It is the Cut medium's discriminator + // data and lives on the base table by deliberate, named exception: + // A CutMetadata satellite (mirroring SessionMetadata/MixMetadata) was considered and + // rejected. ReleaseType is read on every card of the /cuts browse — the highest-traffic + // read in the system. Moving it to a satellite would put a join on that hot path. So it + // stays here. Future media MUST NOT copy this pattern: the default is a satellite metadata + // table; this is the one allowed exception, justified solely by the /cuts read volume. + // + // The "ReleaseType only for Cut" invariant is advisory — enforced at the service layer and + // surfaced via the nullable ReleaseDto.ReleaseType (nulled for non-Cut at the converter). + // It is NOT a DB check constraint by choice, not necessity: EF supports HasCheckConstraint, + // but the invariant is advisory and we keep the schema free of it. builder.Property(e => e.ReleaseType) .IsRequired() .HasConversion() // Store as readable string, not int ordinal @@ -49,6 +61,13 @@ public class ReleaseConfiguration : BaseEntityConfiguration .HasColumnName("release_type") .HasDefaultValue(ReleaseType.Single); + builder.Property(e => e.Medium) + .IsRequired() + .HasConversion() // Store as readable string, not int ordinal + .HasMaxLength(20) + .HasColumnName("medium") + .HasDefaultValue(ReleaseMedium.Cut); // Existing rows migrate to Cut with no data migration. + builder.Property(e => e.CreatedByUserId) .HasColumnName("created_by_user_id"); diff --git a/DeepDrftData/Data/Configurations/SessionMetadataConfiguration.cs b/DeepDrftData/Data/Configurations/SessionMetadataConfiguration.cs new file mode 100644 index 0000000..df01269 --- /dev/null +++ b/DeepDrftData/Data/Configurations/SessionMetadataConfiguration.cs @@ -0,0 +1,48 @@ +using Data.Data.Configurations; +using DeepDrftModels.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DeepDrftData.Data.Configurations; + +public class SessionMetadataConfiguration : BaseEntityConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + // Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index. + base.Configure(builder); + + builder.ToTable("session_metadata"); + + // Map the base audit columns to the snake_case naming the rest of the schema uses. + builder.Property(e => e.Id).HasColumnName("id"); + builder.Property(e => e.CreatedAt).HasColumnName("created_at"); + builder.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + builder.Property(e => e.IsDeleted).HasColumnName("is_deleted"); + + builder.Property(e => e.ReleaseId) + .HasColumnName("release_id"); + + builder.Property(e => e.HeroImageEntryKey) + .IsRequired() + .HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs. + .HasColumnName("hero_image_entry_key"); + + // 1:1 to the parent release. The unique FK index is the DB-level enforcement of the + // one-satellite-per-release cardinality. Cascade on delete: removing the release removes its + // medium satellite (unlike Track's SetNull — a satellite has no meaning without its release). + builder.HasOne(e => e.Release) + .WithOne(r => r.SessionMetadata) + .HasForeignKey(e => e.ReleaseId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => e.ReleaseId) + .IsUnique() + .HasDatabaseName("IX_session_metadata_release_id"); + + // Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already + // calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses + // "IX_session_metadata_is_deleted" regardless of auto-naming conventions. + builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_session_metadata_is_deleted"); + } +} diff --git a/DeepDrftData/Data/DeepDrftContext.cs b/DeepDrftData/Data/DeepDrftContext.cs index 260233b..4cfd9b9 100644 --- a/DeepDrftData/Data/DeepDrftContext.cs +++ b/DeepDrftData/Data/DeepDrftContext.cs @@ -12,6 +12,8 @@ public class DeepDrftContext : DbContext public DbSet Tracks { get; set; } public DbSet Releases { get; set; } + public DbSet SessionMetadata { get; set; } + public DbSet MixMetadata { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -19,5 +21,7 @@ public class DeepDrftContext : DbContext modelBuilder.ApplyConfiguration(new TrackConfiguration()); modelBuilder.ApplyConfiguration(new ReleaseConfiguration()); + modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration()); + modelBuilder.ApplyConfiguration(new MixMetadataConfiguration()); } } diff --git a/DeepDrftData/Migrations/20260613013826_AddReleaseMedium.Designer.cs b/DeepDrftData/Migrations/20260613013826_AddReleaseMedium.Designer.cs new file mode 100644 index 0000000..32ef5c4 --- /dev/null +++ b/DeepDrftData/Migrations/20260613013826_AddReleaseMedium.Designer.cs @@ -0,0 +1,303 @@ +// +using System; +using DeepDrftData.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + [DbContext(typeof(DeepDrftContext))] + [Migration("20260613013826_AddReleaseMedium")] + partial class AddReleaseMedium + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WaveformEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("waveform_entry_key"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_mix_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_mix_metadata_release_id"); + + b.ToTable("mix_metadata", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("artist"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint") + .HasColumnName("created_by_user_id"); + + b.Property("Genre") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("genre"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_path"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Medium") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Cut") + .HasColumnName("medium"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("ReleaseType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Single") + .HasColumnName("release_type"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_release_is_deleted"); + + b.HasIndex("Title", "Artist") + .IsUnique() + .HasDatabaseName("IX_release_title_artist") + .HasFilter("\"is_deleted\" = false"); + + b.ToTable("release", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HeroImageEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("hero_image_entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_session_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_session_metadata_release_id"); + + b.ToTable("session_metadata", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("OriginalFileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("TrackName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("track_name"); + + b.Property("TrackNumber") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("track_number"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_track_is_deleted"); + + b.HasIndex("ReleaseId"); + + b.ToTable("track", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("MixMetadata") + .HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("SessionMetadata") + .HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithMany("Tracks") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Navigation("MixMetadata"); + + b.Navigation("SessionMetadata"); + + b.Navigation("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260613013826_AddReleaseMedium.cs b/DeepDrftData/Migrations/20260613013826_AddReleaseMedium.cs new file mode 100644 index 0000000..5bb95ff --- /dev/null +++ b/DeepDrftData/Migrations/20260613013826_AddReleaseMedium.cs @@ -0,0 +1,106 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class AddReleaseMedium : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "medium", + table: "release", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "Cut"); + + migrationBuilder.CreateTable( + name: "mix_metadata", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + release_id = table.Column(type: "bigint", nullable: false), + waveform_entry_key = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_mix_metadata", x => x.id); + table.ForeignKey( + name: "FK_mix_metadata_release_release_id", + column: x => x.release_id, + principalTable: "release", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "session_metadata", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + release_id = table.Column(type: "bigint", nullable: false), + hero_image_entry_key = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_session_metadata", x => x.id); + table.ForeignKey( + name: "FK_session_metadata_release_release_id", + column: x => x.release_id, + principalTable: "release", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_mix_metadata_is_deleted", + table: "mix_metadata", + column: "is_deleted"); + + migrationBuilder.CreateIndex( + name: "IX_mix_metadata_release_id", + table: "mix_metadata", + column: "release_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_session_metadata_is_deleted", + table: "session_metadata", + column: "is_deleted"); + + migrationBuilder.CreateIndex( + name: "IX_session_metadata_release_id", + table: "session_metadata", + column: "release_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "mix_metadata"); + + migrationBuilder.DropTable( + name: "session_metadata"); + + migrationBuilder.DropColumn( + name: "medium", + table: "release"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 04133da..16400b3 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -22,6 +22,51 @@ namespace DeepDrftData.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WaveformEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("waveform_entry_key"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_mix_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_mix_metadata_release_id"); + + b.ToTable("mix_metadata", (string)null); + }); + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => { b.Property("Id") @@ -61,6 +106,14 @@ namespace DeepDrftData.Migrations .HasDefaultValue(false) .HasColumnName("is_deleted"); + b.Property("Medium") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Cut") + .HasColumnName("medium"); + b.Property("ReleaseDate") .HasColumnType("date") .HasColumnName("release_date"); @@ -96,6 +149,51 @@ namespace DeepDrftData.Migrations b.ToTable("release", (string)null); }); + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HeroImageEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("hero_image_entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_session_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_session_metadata_release_id"); + + b.ToTable("session_metadata", (string)null); + }); + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => { b.Property("Id") @@ -156,6 +254,28 @@ namespace DeepDrftData.Migrations b.ToTable("track", (string)null); }); + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("MixMetadata") + .HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("SessionMetadata") + .HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => { b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") @@ -168,6 +288,10 @@ namespace DeepDrftData.Migrations modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => { + b.Navigation("MixMetadata"); + + b.Navigation("SessionMetadata"); + b.Navigation("Tracks"); }); #pragma warning restore 612, 618 diff --git a/DeepDrftData/TrackConverter.cs b/DeepDrftData/TrackConverter.cs index 0f09cb8..a8c0a8b 100644 --- a/DeepDrftData/TrackConverter.cs +++ b/DeepDrftData/TrackConverter.cs @@ -1,5 +1,6 @@ using DeepDrftModels.DTOs; using DeepDrftModels.Entities; +using DeepDrftModels.Enums; using Models.Converters; namespace DeepDrftData; @@ -25,7 +26,24 @@ public class TrackConverter : IEntityToModelConverter Genre = entity.Genre, ReleaseDate = entity.ReleaseDate, ImagePath = entity.ImagePath, - ReleaseType = entity.ReleaseType, + Medium = entity.Medium, + // ReleaseType is meaningful only for Cut; null it for Session/Mix at the mapping point so no + // consumer mistakes a stale studio-format value for a live/mix release. + ReleaseType = entity.Medium == ReleaseMedium.Cut ? entity.ReleaseType : (ReleaseType?)null, + SessionMetadata = entity.SessionMetadata is null + ? null + : new SessionMetadataDto + { + ReleaseId = entity.SessionMetadata.ReleaseId, + HeroImageEntryKey = entity.SessionMetadata.HeroImageEntryKey + }, + MixMetadata = entity.MixMetadata is null + ? null + : new MixMetadataDto + { + ReleaseId = entity.MixMetadata.ReleaseId, + WaveformEntryKey = entity.MixMetadata.WaveformEntryKey + }, CreatedByUserId = entity.CreatedByUserId }; @@ -39,7 +57,10 @@ public class TrackConverter : IEntityToModelConverter Genre = dto.Genre, ReleaseDate = dto.ReleaseDate, ImagePath = dto.ImagePath, - ReleaseType = dto.ReleaseType, + Medium = dto.Medium, + // Entity ReleaseType is non-nullable; default back to Single when the DTO nulled it for a + // non-Cut release. Primarily a write-path reconstruction concern. + ReleaseType = dto.ReleaseType ?? ReleaseType.Single, CreatedByUserId = dto.CreatedByUserId }; diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index d2bd070..986f73a 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -3,6 +3,7 @@ using Data.Managers; using DeepDrftData.Repositories; using DeepDrftModels.DTOs; using DeepDrftModels.Entities; +using DeepDrftModels.Enums; using Microsoft.Extensions.Logging; using Models.Common; using NetBlocks.Models; @@ -263,7 +264,10 @@ public class TrackManager releaseEntity.Genre = release.Genre; releaseEntity.ReleaseDate = release.ReleaseDate; releaseEntity.ImagePath = release.ImagePath; - releaseEntity.ReleaseType = release.ReleaseType; + releaseEntity.Medium = release.Medium; + // DTO ReleaseType is nullable (meaningful only for Cut); the entity field is not. + // Default to Single when null, matching TrackConverter.Convert(ReleaseDto). + releaseEntity.ReleaseType = release.ReleaseType ?? ReleaseType.Single; releaseEntity.CreatedByUserId = release.CreatedByUserId; await Repository.UpdateReleaseAsync(releaseEntity); } diff --git a/DeepDrftModels/DTOs/MixMetadataDto.cs b/DeepDrftModels/DTOs/MixMetadataDto.cs new file mode 100644 index 0000000..a370c57 --- /dev/null +++ b/DeepDrftModels/DTOs/MixMetadataDto.cs @@ -0,0 +1,10 @@ +namespace DeepDrftModels.DTOs; + +// Mirror of MixMetadata (Phase 9). No `required` members — BlazorBlocks's Manager<> generic +// constraint requires `new()`, which does not compose with required members. TrackConverter assigns +// every field on the round-trip, so an empty default is never observable. +public class MixMetadataDto +{ + public long ReleaseId { get; set; } + public string WaveformEntryKey { get; set; } = string.Empty; +} diff --git a/DeepDrftModels/DTOs/ReleaseDto.cs b/DeepDrftModels/DTOs/ReleaseDto.cs index 9bf1ad5..7d5b7bf 100644 --- a/DeepDrftModels/DTOs/ReleaseDto.cs +++ b/DeepDrftModels/DTOs/ReleaseDto.cs @@ -14,7 +14,16 @@ public class ReleaseDto : BaseModel public string? Genre { get; set; } public DateOnly? ReleaseDate { get; set; } public string? ImagePath { get; set; } - public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; + + // Nullable: meaningful only for Cut releases. TrackConverter nulls it for Session/Mix at the + // mapping point. One producer enforces the rule; no consumer depends on a non-null value. + public ReleaseType? ReleaseType { get; set; } + + // Medium-specific satellites. Populated only for the matching medium; null otherwise. + public SessionMetadataDto? SessionMetadata { get; set; } + public MixMetadataDto? MixMetadata { get; set; } + public long? CreatedByUserId { get; set; } // Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the diff --git a/DeepDrftModels/DTOs/SessionMetadataDto.cs b/DeepDrftModels/DTOs/SessionMetadataDto.cs new file mode 100644 index 0000000..b20b558 --- /dev/null +++ b/DeepDrftModels/DTOs/SessionMetadataDto.cs @@ -0,0 +1,10 @@ +namespace DeepDrftModels.DTOs; + +// Mirror of SessionMetadata (Phase 9). No `required` members — BlazorBlocks's Manager<> generic +// constraint requires `new()`, which does not compose with required members. TrackConverter assigns +// every field on the round-trip, so an empty default is never observable. +public class SessionMetadataDto +{ + public long ReleaseId { get; set; } + public string HeroImageEntryKey { get; set; } = string.Empty; +} diff --git a/DeepDrftModels/Entities/MixMetadata.cs b/DeepDrftModels/Entities/MixMetadata.cs new file mode 100644 index 0000000..b48297f --- /dev/null +++ b/DeepDrftModels/Entities/MixMetadata.cs @@ -0,0 +1,17 @@ +using Models.Entities; + +namespace DeepDrftModels.Entities; + +// 1:1 satellite for Mix-medium releases (Phase 9). One row per Mix ReleaseEntity, keyed by a unique +// ReleaseId FK (the 1:1 enforcement lives in MixMetadataConfiguration). Carries the entry key for +// the preprocessed high-resolution waveform datum. +// +// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models). +// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it +// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc. +public class MixMetadata : BaseEntity, IEntity +{ + public long ReleaseId { get; set; } + public ReleaseEntity Release { get; set; } = null!; + public required string WaveformEntryKey { get; set; } +} diff --git a/DeepDrftModels/Entities/ReleaseEntity.cs b/DeepDrftModels/Entities/ReleaseEntity.cs index 47ee8c0..1ef3aa7 100644 --- a/DeepDrftModels/Entities/ReleaseEntity.cs +++ b/DeepDrftModels/Entities/ReleaseEntity.cs @@ -18,6 +18,13 @@ public class ReleaseEntity : BaseEntity, IEntity public DateOnly? ReleaseDate { get; set; } public string? ImagePath { get; set; } public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; public long? CreatedByUserId { get; set; } public ICollection Tracks { get; set; } = new List(); + + // 1:1 satellites selected by Medium. Null unless this release is the matching medium — + // Session releases carry SessionMetadata, Mix releases carry MixMetadata, Cut releases carry + // neither (ReleaseType on this table is their discriminator data). + public SessionMetadata? SessionMetadata { get; set; } + public MixMetadata? MixMetadata { get; set; } } diff --git a/DeepDrftModels/Entities/SessionMetadata.cs b/DeepDrftModels/Entities/SessionMetadata.cs new file mode 100644 index 0000000..1c7168d --- /dev/null +++ b/DeepDrftModels/Entities/SessionMetadata.cs @@ -0,0 +1,17 @@ +using Models.Entities; + +namespace DeepDrftModels.Entities; + +// 1:1 satellite for Session-medium releases (Phase 9). One row per Session ReleaseEntity, keyed by +// a unique ReleaseId FK (the 1:1 enforcement lives in SessionMetadataConfiguration). Carries the +// hero-image entry key into the Image vault. +// +// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models). +// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it +// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc. +public class SessionMetadata : BaseEntity, IEntity +{ + public long ReleaseId { get; set; } + public ReleaseEntity Release { get; set; } = null!; + public required string HeroImageEntryKey { get; set; } +} diff --git a/DeepDrftModels/Enums/ReleaseMedium.cs b/DeepDrftModels/Enums/ReleaseMedium.cs new file mode 100644 index 0000000..b20482c --- /dev/null +++ b/DeepDrftModels/Enums/ReleaseMedium.cs @@ -0,0 +1,16 @@ +namespace DeepDrftModels.Enums; + +/// +/// The medium of a release — the Phase 9 discriminator that selects which metadata shape applies. +/// +public enum ReleaseMedium +{ + /// Studio recording. Uses (Single/EP/Album). The default. + Cut, + + /// Single live track plus a hero image. Detail in SessionMetadata. + Session, + + /// Single long track plus a preprocessed high-resolution waveform datum. Detail in MixMetadata. + Mix +}