Phase 9 Wave 1: add ReleaseMedium discriminator + Session/Mix metadata

Add ReleaseMedium enum (Cut/Session/Mix) and two 1:1 satellite entities
(SessionMetadata, MixMetadata) with EF configs and an additive migration.
ReleaseDto.ReleaseType is now nullable, nulled for non-Cut at the converter.
Existing releases default to Cut via column default; no data migration.
This commit is contained in:
daniel-c-harvey
2026-06-12 21:47:04 -04:00
parent 6f63fe7d7c
commit 5d6b54d2fc
16 changed files with 767 additions and 4 deletions
@@ -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<MixMetadata>
{
public override void Configure(EntityTypeBuilder<MixMetadata> 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<MixMetadata>(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");
}
}
@@ -42,6 +42,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnName("image_path"); .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) builder.Property(e => e.ReleaseType)
.IsRequired() .IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal .HasConversion<string>() // Store as readable string, not int ordinal
@@ -49,6 +61,13 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasColumnName("release_type") .HasColumnName("release_type")
.HasDefaultValue(ReleaseType.Single); .HasDefaultValue(ReleaseType.Single);
builder.Property(e => e.Medium)
.IsRequired()
.HasConversion<string>() // 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) builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id"); .HasColumnName("created_by_user_id");
@@ -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<SessionMetadata>
{
public override void Configure(EntityTypeBuilder<SessionMetadata> 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<SessionMetadata>(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");
}
}
+4
View File
@@ -12,6 +12,8 @@ public class DeepDrftContext : DbContext
public DbSet<TrackEntity> Tracks { get; set; } public DbSet<TrackEntity> Tracks { get; set; }
public DbSet<ReleaseEntity> Releases { get; set; } public DbSet<ReleaseEntity> Releases { get; set; }
public DbSet<SessionMetadata> SessionMetadata { get; set; }
public DbSet<MixMetadata> MixMetadata { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -19,5 +21,7 @@ public class DeepDrftContext : DbContext
modelBuilder.ApplyConfiguration(new TrackConfiguration()); modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new ReleaseConfiguration()); modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
} }
} }
@@ -0,0 +1,303 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,106 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseMedium : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
release_id = table.Column<long>(type: "bigint", nullable: false),
waveform_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(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<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
release_id = table.Column<long>(type: "bigint", nullable: false),
hero_image_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mix_metadata");
migrationBuilder.DropTable(
name: "session_metadata");
migrationBuilder.DropColumn(
name: "medium",
table: "release");
}
}
}
@@ -22,6 +22,51 @@ namespace DeepDrftData.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("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 => modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -61,6 +106,14 @@ namespace DeepDrftData.Migrations
.HasDefaultValue(false) .HasDefaultValue(false)
.HasColumnName("is_deleted"); .HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate") b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date") .HasColumnType("date")
.HasColumnName("release_date"); .HasColumnName("release_date");
@@ -96,6 +149,51 @@ namespace DeepDrftData.Migrations
b.ToTable("release", (string)null); b.ToTable("release", (string)null);
}); });
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("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 => modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -156,6 +254,28 @@ namespace DeepDrftData.Migrations
b.ToTable("track", (string)null); 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 => modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{ {
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
@@ -168,6 +288,10 @@ namespace DeepDrftData.Migrations
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{ {
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks"); b.Navigation("Tracks");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
+23 -2
View File
@@ -1,5 +1,6 @@
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Models.Converters; using Models.Converters;
namespace DeepDrftData; namespace DeepDrftData;
@@ -25,7 +26,24 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Genre = entity.Genre, Genre = entity.Genre,
ReleaseDate = entity.ReleaseDate, ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath, 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 CreatedByUserId = entity.CreatedByUserId
}; };
@@ -39,7 +57,10 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Genre = dto.Genre, Genre = dto.Genre,
ReleaseDate = dto.ReleaseDate, ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath, 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 CreatedByUserId = dto.CreatedByUserId
}; };
+5 -1
View File
@@ -3,6 +3,7 @@ using Data.Managers;
using DeepDrftData.Repositories; using DeepDrftData.Repositories;
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Models.Common; using Models.Common;
using NetBlocks.Models; using NetBlocks.Models;
@@ -263,7 +264,10 @@ public class TrackManager
releaseEntity.Genre = release.Genre; releaseEntity.Genre = release.Genre;
releaseEntity.ReleaseDate = release.ReleaseDate; releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath; 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; releaseEntity.CreatedByUserId = release.CreatedByUserId;
await Repository.UpdateReleaseAsync(releaseEntity); await Repository.UpdateReleaseAsync(releaseEntity);
} }
+10
View File
@@ -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;
}
+10 -1
View File
@@ -14,7 +14,16 @@ public class ReleaseDto : BaseModel
public string? Genre { get; set; } public string? Genre { get; set; }
public DateOnly? ReleaseDate { get; set; } public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { 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; } public long? CreatedByUserId { get; set; }
// Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the // Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the
+10
View File
@@ -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;
}
+17
View File
@@ -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; }
}
+7
View File
@@ -18,6 +18,13 @@ public class ReleaseEntity : BaseEntity, IEntity
public DateOnly? ReleaseDate { get; set; } public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; } public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
public long? CreatedByUserId { get; set; } public long? CreatedByUserId { get; set; }
public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>(); public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>();
// 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; }
} }
@@ -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; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace DeepDrftModels.Enums;
/// <summary>
/// The medium of a release — the Phase 9 discriminator that selects which metadata shape applies.
/// </summary>
public enum ReleaseMedium
{
/// <summary>Studio recording. Uses <see cref="ReleaseType"/> (Single/EP/Album). The default.</summary>
Cut,
/// <summary>Single live track plus a hero image. Detail in <c>SessionMetadata</c>.</summary>
Session,
/// <summary>Single long track plus a preprocessed high-resolution waveform datum. Detail in <c>MixMetadata</c>.</summary>
Mix
}