From f767d288c5a4b9f6c68d109403f6e05d333fedc6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 12:51:21 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20normalize=20release-cardinal=20fiel?= =?UTF-8?q?ds=20out=20of=20track=20into=20a=20Release=20entity=20(Phase=20?= =?UTF-8?q?8=20=C2=A78.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DeepDrftAPI/Controllers/TrackController.cs | 43 ++-- DeepDrftAPI/Services/UnifiedTrackService.cs | 38 +++- DeepDrftContent/TrackContentService.cs | 8 +- .../Configurations/ReleaseConfiguration.cs | 60 ++++++ .../Data/Configurations/TrackConfiguration.cs | 41 +--- DeepDrftData/Data/DeepDrftContext.cs | 2 + DeepDrftData/ITrackService.cs | 12 +- ...11164537_NormalizeReleaseTrack.Designer.cs | 174 +++++++++++++++++ .../20260611164537_NormalizeReleaseTrack.cs | 184 ++++++++++++++++++ .../DeepDrftContextModelSnapshot.cs | 87 +++++++-- DeepDrftData/Repositories/TrackRepository.cs | 108 ++++++---- DeepDrftData/TrackConverter.cs | 55 ++++-- DeepDrftData/TrackManager.cs | 91 ++++++++- DeepDrftManager/Components/Pages/Index.razor | 2 +- .../Components/Pages/Tracks/TrackEdit.razor | 14 +- .../Components/Pages/Tracks/TrackList.razor | 10 +- DeepDrftManager/Services/CmsTrackService.cs | 26 +-- DeepDrftManager/Services/ICmsTrackService.cs | 4 +- DeepDrftModels/DTOs/AlbumSummaryDto.cs | 1 + DeepDrftModels/DTOs/ReleaseDto.cs | 24 +++ DeepDrftModels/DTOs/TrackDto.cs | 18 +- DeepDrftModels/Entities/ReleaseEntity.cs | 23 +++ DeepDrftModels/Entities/TrackEntity.cs | 14 +- DeepDrftPublic.Client/Clients/TrackClient.cs | 12 +- .../AudioPlayerBar/TrackMetaLabel.razor | 10 +- .../Controls/NowPlayingCard.razor | 2 +- .../Controls/TrackCard.razor | 54 ++--- DeepDrftPublic.Client/Pages/AlbumsView.razor | 8 +- .../Pages/AlbumsView.razor.cs | 2 +- DeepDrftPublic.Client/Pages/TrackDetail.razor | 22 ++- .../Services/ITrackDataService.cs | 4 +- .../Services/TrackClientDataService.cs | 2 +- DeepDrftTests/TrackFilterQueryTests.cs | 174 +++++++++++------ 33 files changed, 1032 insertions(+), 297 deletions(-) create mode 100644 DeepDrftData/Data/Configurations/ReleaseConfiguration.cs create mode 100644 DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.Designer.cs create mode 100644 DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.cs create mode 100644 DeepDrftModels/DTOs/ReleaseDto.cs create mode 100644 DeepDrftModels/Entities/ReleaseEntity.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index bb96b6d..98b7d6d 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -77,12 +77,13 @@ public class TrackController : ControllerBase } // GET api/track/albums (unauthenticated) - // Distinct non-null albums with track counts and cover keys. Public browse data, same posture as - // GET api/track/page. Literal segment, declared before the parameterized "{trackId}" route. + // All releases with per-release track counts. Public browse data, same posture as GET + // api/track/page. Literal segment, declared before the parameterized "{trackId}" route. + // Route name kept as "albums" for client/proxy compatibility; the payload is List. [HttpGet("albums")] public async Task GetAlbums(CancellationToken ct = default) { - var result = await _sqlTrackService.GetDistinctAlbums(ct); + var result = await _sqlTrackService.GetReleases(ct); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; @@ -367,23 +368,33 @@ public class TrackController : ControllerBase return BadRequest("trackNumber must be a positive integer when provided."); var track = lookup.Value; + + // Track-cardinal fields update the track row directly. track.TrackName = request.TrackName; - track.Artist = request.Artist; - track.Album = request.Album; - track.Genre = request.Genre; - track.ReleaseDate = request.ReleaseDate; - - // Only update ImagePath when the request explicitly provides a value (null = no change, "" = clear). - if (request.ImagePath is not null) - track.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath; - - // ReleaseType / TrackNumber are non-null on the entity; null in the request means "no change". - if (request.ReleaseType is not null) - track.ReleaseType = request.ReleaseType.Value; - if (request.TrackNumber is > 0) track.TrackNumber = request.TrackNumber.Value; + // Release-cardinal fields update the linked release (handled in TrackManager.Update, which + // persists track.Release when the track carries a resolved ReleaseId). The loaded track has + // its Release populated via the Include; mutate it in place so the edited values flow through. + // A loose track (no release) cannot take release-cardinal edits — there is no release row to + // write to — so these fields are simply not persisted in that case. + if (track.Release is { } release) + { + release.Artist = request.Artist; + release.Title = request.Album ?? string.Empty; + release.Genre = request.Genre; + release.ReleaseDate = request.ReleaseDate; + + // ImagePath is tri-state: null = no change, "" = clear, value = set. + if (request.ImagePath is not null) + release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath; + + // ReleaseType is non-null on the release; null in the request means "no change". + if (request.ReleaseType is not null) + release.ReleaseType = request.ReleaseType.Value; + } + var update = await _sqlTrackService.Update(track); if (!update.Success) { diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index a5b2223..2949e25 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -65,11 +65,43 @@ public class UnifiedTrackService return ResultContainer.CreateFailResult("Failed to process and store WAV."); } - unpersisted.CreatedByUserId = createdByUserId; - unpersisted.ReleaseType = releaseType; unpersisted.TrackNumber = trackNumber; - var saveResult = await _sqlTrackService.Create(TrackConverter.Convert(unpersisted)); + // Resolve the release FK before persisting the track. An upload with an album lands on the + // shared release (created on first sighting); an upload without one stays a loose track with + // a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides + // on the release, not the track. + long? releaseId = null; + if (!string.IsNullOrWhiteSpace(album)) + { + var releaseData = new ReleaseDto + { + Title = album, + Artist = artist, + Genre = genre, + ReleaseDate = releaseDate, + ReleaseType = releaseType, + CreatedByUserId = createdByUserId, + }; + + var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct); + if (!releaseResult.Success || releaseResult.Value is null) + { + var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError( + "Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}", + unpersisted.EntryKey, error); + return ResultContainer.CreateFailResult($"Track was uploaded but could not be saved: {error}"); + } + + releaseId = releaseResult.Value.Id; + } + + var trackDto = TrackConverter.Convert(unpersisted); + trackDto.ReleaseId = releaseId; + trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph. + + var saveResult = await _sqlTrackService.Create(trackDto); if (!saveResult.Success || saveResult.Value is null) { // Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault diff --git a/DeepDrftContent/TrackContentService.cs b/DeepDrftContent/TrackContentService.cs index 52d2dde..bf6d13c 100644 --- a/DeepDrftContent/TrackContentService.cs +++ b/DeepDrftContent/TrackContentService.cs @@ -67,15 +67,13 @@ public class TrackContentService throw new InvalidOperationException("Failed to store audio in FileDatabase"); } - // Create the track entity for SQL database + // Create the track entity for SQL database. Post Phase 8 §8.0 the entity holds only + // track-cardinal fields; release-cardinal data (artist/album/genre/releaseDate) is + // resolved into a ReleaseEntity by the caller (UnifiedTrackService) and linked via FK. var trackEntity = new TrackEntity { EntryKey = trackId, // FileDatabase entry ID TrackName = trackName, - Artist = artist, - Album = album, - Genre = genre, - ReleaseDate = releaseDate, OriginalFileName = originalFileName }; diff --git a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs new file mode 100644 index 0000000..cd49e94 --- /dev/null +++ b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs @@ -0,0 +1,60 @@ +using Data.Data.Configurations; +using DeepDrftModels.Entities; +using DeepDrftModels.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DeepDrftData.Data.Configurations; + +public class ReleaseConfiguration : 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("release"); + + // 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.Title) + .IsRequired() + .HasMaxLength(200) + .HasColumnName("title"); + + builder.Property(e => e.Artist) + .IsRequired() + .HasMaxLength(200) + .HasColumnName("artist"); + + builder.Property(e => e.Genre) + .HasMaxLength(100) + .HasColumnName("genre"); + + builder.Property(e => e.ReleaseDate) + .HasColumnName("release_date"); + + builder.Property(e => e.ImagePath) + .HasMaxLength(500) + .HasColumnName("image_path"); + + builder.Property(e => e.ReleaseType) + .IsRequired() + .HasConversion() // Store as readable string, not int ordinal + .HasMaxLength(20) + .HasColumnName("release_type") + .HasDefaultValue(ReleaseType.Single); + + builder.Property(e => e.CreatedByUserId) + .HasColumnName("created_by_user_id"); + + // Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already + // calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses + // "IX_release_is_deleted" regardless of auto-naming conventions. + builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_release_is_deleted"); + } +} diff --git a/DeepDrftData/Data/Configurations/TrackConfiguration.cs b/DeepDrftData/Data/Configurations/TrackConfiguration.cs index c42bdfd..7151478 100644 --- a/DeepDrftData/Data/Configurations/TrackConfiguration.cs +++ b/DeepDrftData/Data/Configurations/TrackConfiguration.cs @@ -1,6 +1,5 @@ using Data.Data.Configurations; using DeepDrftModels.Entities; -using DeepDrftModels.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -31,45 +30,25 @@ public class TrackConfiguration : BaseEntityConfiguration .HasMaxLength(200) .HasColumnName("track_name"); - builder.Property(e => e.Artist) - .IsRequired() - .HasMaxLength(200) - .HasColumnName("artist"); - - builder.Property(e => e.Album) - .HasMaxLength(200) - .HasColumnName("album"); - - builder.Property(e => e.Genre) - .HasMaxLength(100) - .HasColumnName("genre"); - - builder.Property(e => e.ReleaseDate) - .HasColumnName("release_date"); - - builder.Property(e => e.ImagePath) - .HasMaxLength(500) - .HasColumnName("image_path"); - - builder.Property(e => e.CreatedByUserId) - .HasColumnName("created_by_user_id"); - builder.Property(e => e.OriginalFileName) .HasMaxLength(500) .HasColumnName("original_file_name"); - builder.Property(e => e.ReleaseType) - .IsRequired() - .HasConversion() // Store as readable string, not int ordinal - .HasMaxLength(20) - .HasColumnName("release_type") - .HasDefaultValue(ReleaseType.Single); - builder.Property(e => e.TrackNumber) .IsRequired() .HasColumnName("track_number") .HasDefaultValue(1); + builder.Property(e => e.ReleaseId) + .HasColumnName("release_id"); + + // Nullable FK to the release-cardinal row. SetNull on delete: removing a release leaves its + // tracks intact as loose tracks rather than cascading them away. + builder.HasOne(e => e.Release) + .WithMany(r => r.Tracks) + .HasForeignKey(e => e.ReleaseId) + .OnDelete(DeleteBehavior.SetNull); + // Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already // calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses // "IX_track_is_deleted" regardless of auto-naming conventions. diff --git a/DeepDrftData/Data/DeepDrftContext.cs b/DeepDrftData/Data/DeepDrftContext.cs index b0839dd..260233b 100644 --- a/DeepDrftData/Data/DeepDrftContext.cs +++ b/DeepDrftData/Data/DeepDrftContext.cs @@ -11,11 +11,13 @@ public class DeepDrftContext : DbContext } public DbSet Tracks { get; set; } + public DbSet Releases { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfiguration(new TrackConfiguration()); + modelBuilder.ApplyConfiguration(new ReleaseConfiguration()); } } diff --git a/DeepDrftData/ITrackService.cs b/DeepDrftData/ITrackService.cs index 761af6a..6cd1262 100644 --- a/DeepDrftData/ITrackService.cs +++ b/DeepDrftData/ITrackService.cs @@ -22,12 +22,20 @@ public interface ITrackService Task>> GetAll(); Task>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default); - /// Distinct non-null albums with track counts and a representative cover key, album-ascending. - Task>> GetDistinctAlbums(CancellationToken cancellationToken = default); + /// All releases, title-ascending, each carrying its non-deleted track count. + Task>> GetReleases(CancellationToken cancellationToken = default); /// Distinct non-null genres with track counts, genre-ascending. Task>> GetDistinctGenres(CancellationToken cancellationToken = default); + /// + /// Resolve the release matching + , creating + /// one from when none exists. Backs the upload flow's FK + /// resolution so a track lands on a shared release rather than duplicating release-cardinal data. + /// + Task> FindOrCreateRelease( + string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default); + Task> Create(TrackDto newTrack); Task> Update(TrackDto track); Task Delete(long id); diff --git a/DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.Designer.cs b/DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.Designer.cs new file mode 100644 index 0000000..c7bbc1e --- /dev/null +++ b/DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.Designer.cs @@ -0,0 +1,174 @@ +// +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("20260611164537_NormalizeReleaseTrack")] + partial class NormalizeReleaseTrack + { + /// + 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.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("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.ToTable("release", (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.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("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.cs b/DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.cs new file mode 100644 index 0000000..84b968d --- /dev/null +++ b/DeepDrftData/Migrations/20260611164537_NormalizeReleaseTrack.cs @@ -0,0 +1,184 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class NormalizeReleaseTrack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. Create the release table. + migrationBuilder.CreateTable( + name: "release", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + artist = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + genre = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + release_date = table.Column(type: "date", nullable: true), + image_path = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + release_type = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Single"), + created_by_user_id = table.Column(type: "bigint", nullable: true), + 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_release", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_release_is_deleted", + table: "release", + column: "is_deleted"); + + // 2. Add the nullable FK column to track. A fresh column (not a rename of + // created_by_user_id) so existing rows start with a null release until back-filled. + migrationBuilder.AddColumn( + name: "release_id", + table: "track", + type: "bigint", + nullable: true); + + // 3. Data migration — must run after the release table exists and release_id is added, + // and before the release-cardinal columns are dropped from track (the SELECT reads them). + // Create one release row per distinct (album, artist) from existing tracks, carrying + // the release-cardinal fields. Tracks with a null album remain release_id = null. + migrationBuilder.Sql(@" + INSERT INTO release (title, artist, genre, release_date, image_path, release_type, + created_by_user_id, created_at, updated_at, is_deleted) + SELECT DISTINCT ON (album, artist) + album, artist, genre, release_date, image_path, release_type, + created_by_user_id, NOW(), NOW(), false + FROM track + WHERE album IS NOT NULL + ORDER BY album, artist, id; + "); + + // Back-fill the FK: match each track to the release created from its (album, artist). + migrationBuilder.Sql(@" + UPDATE track + SET release_id = r.id + FROM release r + WHERE track.album = r.title + AND track.artist = r.artist; + "); + + // 4. Index + FK now that the column carries its back-filled values. + migrationBuilder.CreateIndex( + name: "IX_track_release_id", + table: "track", + column: "release_id"); + + migrationBuilder.AddForeignKey( + name: "FK_track_release_release_id", + table: "track", + column: "release_id", + principalTable: "release", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + + // 5. Drop the now-migrated release-cardinal columns from track. + migrationBuilder.DropColumn(name: "album", table: "track"); + migrationBuilder.DropColumn(name: "artist", table: "track"); + migrationBuilder.DropColumn(name: "genre", table: "track"); + migrationBuilder.DropColumn(name: "image_path", table: "track"); + migrationBuilder.DropColumn(name: "release_date", table: "track"); + migrationBuilder.DropColumn(name: "release_type", table: "track"); + migrationBuilder.DropColumn(name: "created_by_user_id", table: "track"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // 1. Re-add the track release-cardinal columns. artist is non-nullable with a default so + // the add succeeds against existing rows before the back-fill repopulates it. + migrationBuilder.AddColumn( + name: "album", + table: "track", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "artist", + table: "track", + type: "character varying(200)", + maxLength: 200, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "genre", + table: "track", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "image_path", + table: "track", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "release_date", + table: "track", + type: "date", + nullable: true); + + migrationBuilder.AddColumn( + name: "release_type", + table: "track", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "Single"); + + migrationBuilder.AddColumn( + name: "created_by_user_id", + table: "track", + type: "bigint", + nullable: true); + + // 2. Re-populate the track columns from the release join before the release table and FK go. + migrationBuilder.Sql(@" + UPDATE track + SET artist = r.artist, + album = r.title, + genre = r.genre, + release_date = r.release_date, + image_path = r.image_path, + release_type = r.release_type, + created_by_user_id = r.created_by_user_id + FROM release r + WHERE track.release_id = r.id; + "); + + // 3. Drop the FK, index, the release_id column, and the release table. + migrationBuilder.DropForeignKey( + name: "FK_track_release_release_id", + table: "track"); + + migrationBuilder.DropIndex( + name: "IX_track_release_id", + table: "track"); + + migrationBuilder.DropColumn( + name: "release_id", + table: "track"); + + migrationBuilder.DropTable( + name: "release"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 00c5a77..84aa92a 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -31,11 +31,6 @@ namespace DeepDrftData.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("Album") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("album"); - b.Property("Artist") .IsRequired() .HasMaxLength(200) @@ -50,12 +45,6 @@ namespace DeepDrftData.Migrations .HasColumnType("bigint") .HasColumnName("created_by_user_id"); - b.Property("EntryKey") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("entry_key"); - b.Property("Genre") .HasMaxLength(100) .HasColumnType("character varying(100)") @@ -72,11 +61,6 @@ namespace DeepDrftData.Migrations .HasDefaultValue(false) .HasColumnName("is_deleted"); - b.Property("OriginalFileName") - .HasMaxLength(500) - .HasColumnType("character varying(500)") - .HasColumnName("original_file_name"); - b.Property("ReleaseDate") .HasColumnType("date") .HasColumnName("release_date"); @@ -89,6 +73,58 @@ namespace DeepDrftData.Migrations .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.ToTable("release", (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) @@ -110,8 +146,25 @@ namespace DeepDrftData.Migrations b.HasIndex("IsDeleted") .HasDatabaseName("IX_track_is_deleted"); + b.HasIndex("ReleaseId"); + b.ToTable("track", (string)null); }); + + 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("Tracks"); + }); #pragma warning restore 612, 618 } } diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index 5b51cac..b10c602 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -11,18 +11,26 @@ namespace DeepDrftData.Repositories; public class TrackRepository : Repository { + // The base Repository<> exposes Query (soft-delete-filtered IQueryable) but no + // DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference + // to the injected context rather than reaching for a service locator — it is the same scoped + // instance the base holds, so reads/writes stay in one unit of work. + private readonly DeepDrftContext _context; + public TrackRepository( DeepDrftContext context, ILogger> logger, IDbExceptionClassifier? classifier = null) : base(context, logger, classifier: classifier) { + _context = context; } // Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this - // uses Query (soft-delete filtered) rather than the raw DbSet. + // uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the + // converter can project the release-cardinal fields. public async Task GetByEntryKeyAsync(string entryKey) - => await Query.FirstOrDefaultAsync(t => t.EntryKey == entryKey); + => await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey); // Picks one track uniformly at random. Two round-trips (count, then a single offset row) // rather than ORDER BY random() so the database never sorts the whole table — the catalogue @@ -37,6 +45,7 @@ public class TrackRepository : Repository var index = Random.Shared.Next(count); return await Query + .Include(t => t.Release) .OrderBy(t => t.Id) .Skip(index) .Take(1) @@ -53,27 +62,29 @@ public class TrackRepository : Repository TrackFilter? filter, CancellationToken ct = default) { - IQueryable query = Query; + // Include Release so both the filter predicates and the converter can read release-cardinal + // fields through the navigation. + IQueryable query = Query.Include(t => t.Release); if (filter is not null) { if (!string.IsNullOrWhiteSpace(filter.SearchText)) { // Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is - // EF-translatable where ToLower().Contains() is not. Album is nullable — ILike on a - // null column yields false, which is the desired "no match" behaviour. + // EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined + // Release, which is null for loose tracks — guard the navigation before ILike. var pattern = $"%{filter.SearchText}%"; query = query.Where(t => EF.Functions.ILike(t.TrackName, pattern) - || EF.Functions.ILike(t.Artist, pattern) - || (t.Album != null && EF.Functions.ILike(t.Album, pattern))); + || (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern)) + || (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern))); } if (!string.IsNullOrWhiteSpace(filter.Album)) - query = query.Where(t => t.Album == filter.Album); + query = query.Where(t => t.Release != null && t.Release.Title == filter.Album); if (!string.IsNullOrWhiteSpace(filter.Genre)) - query = query.Where(t => t.Genre == filter.Genre); + query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre); } var totalCount = await query.CountAsync(ct); @@ -99,30 +110,21 @@ public class TrackRepository : Repository }; } - // Distinct albums (non-null) with track counts and a representative cover key. The cover is the - // first non-null ImagePath in the group; GroupBy + projection keeps it a single round-trip. - public async Task> GetDistinctAlbumsAsync(CancellationToken ct = default) - => await Query - .Where(t => t.Album != null) - .GroupBy(t => t.Album!) - .Select(g => new AlbumSummaryDto - { - Album = g.Key, - TrackCount = g.Count(), - CoverImageKey = g - .Where(t => t.ImagePath != null) - .OrderBy(t => t.Id) - .Select(t => t.ImagePath) - .FirstOrDefault(), - }) - .OrderBy(a => a.Album) + // All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks. + // The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto. + public async Task> GetReleasesAsync(CancellationToken ct = default) + => await _context.Set() + .Where(r => !r.IsDeleted) + .OrderBy(r => r.Title) .ToListAsync(ct); - // Distinct genres (non-null) with track counts. + // Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks + // (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks + // (no release) carry no genre and are excluded. public async Task> GetDistinctGenresAsync(CancellationToken ct = default) => await Query - .Where(t => t.Genre != null) - .GroupBy(t => t.Genre!) + .Where(t => t.Release != null && t.Release.Genre != null) + .GroupBy(t => t.Release!.Genre!) .Select(g => new GenreSummaryDto { Genre = g.Key, @@ -131,16 +133,52 @@ public class TrackRepository : Repository .OrderBy(g => g.Genre) .ToListAsync(ct); + // Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against + // GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out. + public async Task> GetTrackCountsByReleaseAsync(CancellationToken ct = default) + => await Query + .Where(t => t.ReleaseId != null) + .GroupBy(t => t.ReleaseId!.Value) + .Select(g => new { ReleaseId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct); + + // Resolve an existing release by its natural key (title + artist). Returns null when no match, + // signalling the manager to create one. Soft-deleted releases never match. + public async Task GetReleaseByTitleAndArtistAsync( + string title, string artist, CancellationToken ct = default) + => await _context.Set() + .FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct); + + // Persist a new release row and return it with its assigned Id. Lives here (not the manager) + // because the repository owns the DbContext — the manager stays free of direct context access. + public async Task AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default) + { + _context.Set().Add(release); + await _context.SaveChangesAsync(ct); + return release; + } + + // Load a tracked release by id so the manager can edit its fields in place and save. Returns + // null when the id does not resolve (or the release is soft-deleted). + public async Task GetReleaseByIdAsync(long id, CancellationToken ct = default) + => await _context.Set() + .FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct); + + // Persist edits to a release. Update marks the whole entity modified, so it works whether the + // instance is the change-tracked one from GetReleaseByIdAsync or a detached graph. + public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default) + { + _context.Set().Update(release); + await _context.SaveChangesAsync(ct); + } + protected override void UpdateEntity(TrackEntity target, TrackEntity source) { base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted target.EntryKey = source.EntryKey; target.TrackName = source.TrackName; - target.Artist = source.Artist; - target.Album = source.Album; - target.Genre = source.Genre; - target.ReleaseDate = source.ReleaseDate; - target.ImagePath = source.ImagePath; - target.CreatedByUserId = source.CreatedByUserId; + target.TrackNumber = source.TrackNumber; + target.OriginalFileName = source.OriginalFileName; + target.ReleaseId = source.ReleaseId; } } diff --git a/DeepDrftData/TrackConverter.cs b/DeepDrftData/TrackConverter.cs index 7ebd13e..0f09cb8 100644 --- a/DeepDrftData/TrackConverter.cs +++ b/DeepDrftData/TrackConverter.cs @@ -9,9 +9,40 @@ namespace DeepDrftData; /// The DTO side mirrors the entity field-for-field; the audit columns /// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel. /// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model. +/// +/// Post Phase 8 §8.0: TrackEntity carries only track-cardinal fields plus a nullable +/// ReleaseId/Release. The release-cardinal data converts through the Release maps below. /// public class TrackConverter : IEntityToModelConverter { + public static ReleaseDto Convert(ReleaseEntity entity) => new() + { + Id = entity.Id, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + Title = entity.Title, + Artist = entity.Artist, + Genre = entity.Genre, + ReleaseDate = entity.ReleaseDate, + ImagePath = entity.ImagePath, + ReleaseType = entity.ReleaseType, + CreatedByUserId = entity.CreatedByUserId + }; + + public static ReleaseEntity Convert(ReleaseDto dto) => new() + { + Id = dto.Id, + CreatedAt = dto.CreatedAt, + UpdatedAt = dto.UpdatedAt, + Title = dto.Title, + Artist = dto.Artist, + Genre = dto.Genre, + ReleaseDate = dto.ReleaseDate, + ImagePath = dto.ImagePath, + ReleaseType = dto.ReleaseType, + CreatedByUserId = dto.CreatedByUserId + }; + public static TrackDto Convert(TrackEntity entity) => new() { Id = entity.Id, @@ -19,17 +50,15 @@ public class TrackConverter : IEntityToModelConverter UpdatedAt = entity.UpdatedAt, EntryKey = entity.EntryKey, TrackName = entity.TrackName, - Artist = entity.Artist, - Album = entity.Album, - Genre = entity.Genre, - ReleaseDate = entity.ReleaseDate, - ImagePath = entity.ImagePath, - CreatedByUserId = entity.CreatedByUserId, OriginalFileName = entity.OriginalFileName, - ReleaseType = entity.ReleaseType, - TrackNumber = entity.TrackNumber + TrackNumber = entity.TrackNumber, + ReleaseId = entity.ReleaseId, + Release = entity.Release is null ? null : Convert(entity.Release) }; + // DTO → entity maps track-cardinal fields + ReleaseId only. The Release navigation is left + // unset: the manager resolves/attaches the release row against the tracked context so a detached + // graph never overwrites a shared release record. public static TrackEntity Convert(TrackDto model) => new() { Id = model.Id, @@ -37,14 +66,8 @@ public class TrackConverter : IEntityToModelConverter UpdatedAt = model.UpdatedAt, EntryKey = model.EntryKey, TrackName = model.TrackName, - Artist = model.Artist, - Album = model.Album, - Genre = model.Genre, - ReleaseDate = model.ReleaseDate, - ImagePath = model.ImagePath, - CreatedByUserId = model.CreatedByUserId, OriginalFileName = model.OriginalFileName, - ReleaseType = model.ReleaseType, - TrackNumber = model.TrackNumber + TrackNumber = model.TrackNumber, + ReleaseId = model.ReleaseId }; } diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 47eb4fc..e92e37e 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -107,13 +107,16 @@ public class TrackManager Page = pageNumber, PageSize = pageSize, IsDescending = sortDescending, + // Sorts navigate through the nullable Release relation; the null-coalescing + // sentinels push loose tracks (no release) to the end, matching the prior + // nulls-last behaviour on the flat columns. OrderBy = sortColumn switch { "TrackName" => e => e.TrackName, - "Artist" => e => e.Artist, - "Album" => e => (object)(e.Album ?? string.Empty), - "Genre" => e => (object)(e.Genre ?? string.Empty), - "ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue), + "Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist), + "Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title), + "Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)), + "ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)), _ => e => e.Id } }; @@ -135,16 +138,52 @@ public class TrackManager } } - public async Task>> GetDistinctAlbums(CancellationToken cancellationToken = default) + public async Task>> GetReleases(CancellationToken cancellationToken = default) { try { - var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken); - return ResultContainer>.CreatePassResult(albums); + var releases = await Repository.GetReleasesAsync(cancellationToken); + var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken); + + var dtos = releases + .Select(r => + { + var dto = TrackConverter.Convert(r); + dto.TrackCount = counts.GetValueOrDefault(r.Id); + return dto; + }) + .ToList(); + + return ResultContainer>.CreatePassResult(dtos); } catch (Exception e) { - return ResultContainer>.CreateFailResult(e.Message); + return ResultContainer>.CreateFailResult(e.Message); + } + } + + public async Task> FindOrCreateRelease( + string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default) + { + try + { + var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken); + if (existing is not null) + return ResultContainer.CreatePassResult(TrackConverter.Convert(existing)); + + // The natural key (title + artist) is authoritative — override whatever the caller put + // in releaseData so a typo upstream cannot create a release that won't be found again. + var entity = TrackConverter.Convert(releaseData); + entity.Id = 0; + entity.Title = title; + entity.Artist = artist; + + var added = await Repository.AddReleaseAsync(entity, cancellationToken); + return ResultContainer.CreatePassResult(TrackConverter.Convert(added)); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); } } @@ -165,6 +204,22 @@ public class TrackManager { try { + // A track with release context resolves (or creates) the shared release first so the FK + // is set before insert. A standalone track (Release null) stays a loose track, ReleaseId + // null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and + // a populated ReleaseId, which falls straight through. + if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title)) + { + var resolved = await FindOrCreateRelease(release.Title, release.Artist, release); + if (!resolved.Success || resolved.Value is null) + { + var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release."; + return ResultContainer.CreateFailResult(error); + } + + newTrack.ReleaseId = resolved.Value.Id; + } + var added = await Repository.AddAsync(TrackConverter.Convert(newTrack)); return ResultContainer.CreatePassResult(TrackConverter.Convert(added)); } @@ -181,6 +236,26 @@ public class TrackManager try { await Repository.UpdateAsync(TrackConverter.Convert(track)); + + // Release-cardinal edits flow through the linked release row, not the track. When the + // track carries a Release payload and a resolved FK, load the tracked release, apply the + // edited fields, and save. EntryKey/track fields are already persisted above. + if (track.Release is { } release && track.ReleaseId is { } releaseId) + { + var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId); + if (releaseEntity is not null) + { + releaseEntity.Title = release.Title; + releaseEntity.Artist = release.Artist; + releaseEntity.Genre = release.Genre; + releaseEntity.ReleaseDate = release.ReleaseDate; + releaseEntity.ImagePath = release.ImagePath; + releaseEntity.ReleaseType = release.ReleaseType; + releaseEntity.CreatedByUserId = release.CreatedByUserId; + await Repository.UpdateReleaseAsync(releaseEntity); + } + } + var updated = await Repository.GetByIdAsync(track.Id); return updated is not null ? ResultContainer.CreatePassResult(TrackConverter.Convert(updated)) diff --git a/DeepDrftManager/Components/Pages/Index.razor b/DeepDrftManager/Components/Pages/Index.razor index 2746a65..7ca4a16 100644 --- a/DeepDrftManager/Components/Pages/Index.razor +++ b/DeepDrftManager/Components/Pages/Index.razor @@ -63,7 +63,7 @@ { try { - var result = await CmsTrackService.GetAlbumSummariesAsync(); + var result = await CmsTrackService.GetReleasesAsync(); _albumCount = result.Success && result.Value is not null ? result.Value.Count : null; if (!result.Success) { diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index 745e1bb..2be6c06 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -264,7 +264,7 @@ var confirmed = await DialogService.ShowMessageBox( "Delete track", - $"Permanently delete \"{_track.TrackName}\" by {_track.Artist}? This cannot be undone.", + $"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.", yesText: "Delete", cancelText: "Cancel"); @@ -310,14 +310,14 @@ public static TrackEditForm From(TrackDto track) => new() { TrackName = track.TrackName, - Artist = track.Artist, - Album = track.Album, - Genre = track.Genre, - ImagePath = track.ImagePath, - ReleaseDate = track.ReleaseDate is { } d + Artist = track.Release?.Artist ?? string.Empty, + Album = track.Release?.Title, + Genre = track.Release?.Genre, + ImagePath = track.Release?.ImagePath, + ReleaseDate = track.Release?.ReleaseDate is { } d ? d.ToDateTime(TimeOnly.MinValue) : null, - ReleaseType = track.ReleaseType, + ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single, TrackNumber = track.TrackNumber }; } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index 7505d40..c59b951 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -52,10 +52,10 @@ @context.TrackName - @context.Artist - @(context.Album ?? "—") - @(context.Genre ?? "—") - @(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—") + @(context.Release?.Artist ?? "—") + @(context.Release?.Title ?? "—") + @(context.Release?.Genre ?? "—") + @(context.Release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—") @context.EntryKey @(context.OriginalFileName ?? "—") @@ -216,7 +216,7 @@ { var confirmed = await DialogService.ShowMessageBox( title: "Delete track", - markupMessage: new MarkupString($"Delete {WebUtility.HtmlEncode(track.TrackName)} by {WebUtility.HtmlEncode(track.Artist)}? This removes both the metadata row and the underlying audio entry."), + markupMessage: new MarkupString($"Delete {WebUtility.HtmlEncode(track.TrackName)} by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."), yesText: "Delete", cancelText: "Cancel"); diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index 565f850..c102ec2 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -450,7 +450,7 @@ public class CmsTrackService : ICmsTrackService } } - public async Task>> GetAlbumSummariesAsync(CancellationToken ct = default) + public async Task>> GetReleasesAsync(CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); @@ -461,36 +461,36 @@ public class CmsTrackService : ICmsTrackService } catch (Exception ex) { - _logger.LogError(ex, "Content API call failed for album summaries"); - return ResultContainer>.CreateFailResult("Content API is unreachable."); + _logger.LogError(ex, "Content API call failed for releases"); + return ResultContainer>.CreateFailResult("Content API is unreachable."); } using (response) { if (!response.IsSuccessStatusCode) { - _logger.LogError("Content API album summaries failed: {Status}", (int)response.StatusCode); - return ResultContainer>.CreateFailResult("Failed to load albums."); + _logger.LogError("Content API releases failed: {Status}", (int)response.StatusCode); + return ResultContainer>.CreateFailResult("Failed to load albums."); } - List? albums; + List? releases; try { - albums = await response.Content.ReadFromJsonAsync>(ct); + releases = await response.Content.ReadFromJsonAsync>(ct); } catch (Exception ex) { - _logger.LogError(ex, "Failed to deserialize album summaries from Content API response"); - return ResultContainer>.CreateFailResult("Content API returned an unexpected response."); + _logger.LogError(ex, "Failed to deserialize releases from Content API response"); + return ResultContainer>.CreateFailResult("Content API returned an unexpected response."); } - if (albums is null) + if (releases is null) { - _logger.LogError("Content API returned a null album summaries list"); - return ResultContainer>.CreateFailResult("Content API returned an empty response."); + _logger.LogError("Content API returned a null releases list"); + return ResultContainer>.CreateFailResult("Content API returned an empty response."); } - return ResultContainer>.CreatePassResult(albums); + return ResultContainer>.CreatePassResult(releases); } } diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 6902078..a8f7fbd 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -88,8 +88,8 @@ public interface ICmsTrackService /// Task GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default); - /// Returns all distinct albums with track counts from GET api/track/albums. - Task>> GetAlbumSummariesAsync(CancellationToken ct = default); + /// Returns all releases with track counts from GET api/track/albums. + Task>> GetReleasesAsync(CancellationToken ct = default); /// Returns all distinct genres with track counts from GET api/track/genres. Task>> GetGenreSummariesAsync(CancellationToken ct = default); diff --git a/DeepDrftModels/DTOs/AlbumSummaryDto.cs b/DeepDrftModels/DTOs/AlbumSummaryDto.cs index 92a7d42..b6ae672 100644 --- a/DeepDrftModels/DTOs/AlbumSummaryDto.cs +++ b/DeepDrftModels/DTOs/AlbumSummaryDto.cs @@ -4,6 +4,7 @@ namespace DeepDrftModels.DTOs; /// One distinct album with its track count and a representative cover image key. Backs the /// /albums browse grid. /// +[Obsolete("Replaced by ReleaseDto. Use ITrackService.GetReleases().")] public class AlbumSummaryDto { public required string Album { get; set; } diff --git a/DeepDrftModels/DTOs/ReleaseDto.cs b/DeepDrftModels/DTOs/ReleaseDto.cs new file mode 100644 index 0000000..9bf1ad5 --- /dev/null +++ b/DeepDrftModels/DTOs/ReleaseDto.cs @@ -0,0 +1,24 @@ +using DeepDrftModels.Enums; +using Models.Models; + +namespace DeepDrftModels.DTOs; + +// Mirror of ReleaseEntity (Phase 8 §8.0). Inherits Id, CreatedAt, UpdatedAt from BaseModel +// (Cerebellum.BlazorBlocks.Models). No `required` members — BlazorBlocks's Manager<> generic +// constraint requires `new()`, which does not compose with required members (see TrackDto header). +// TrackConverter assigns every field on the round-trip, so an empty default is never observable. +public class ReleaseDto : BaseModel +{ + public string Title { get; set; } = string.Empty; + public string Artist { get; set; } = string.Empty; + public string? Genre { get; set; } + public DateOnly? ReleaseDate { get; set; } + public string? ImagePath { get; set; } + public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + public long? CreatedByUserId { get; set; } + + // Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the + // service projects it from the joined Tracks collection so the /albums browse grid and the CMS + // dashboard can show a per-album track count. Defaults to 0 when not populated. + public int TrackCount { get; set; } +} diff --git a/DeepDrftModels/DTOs/TrackDto.cs b/DeepDrftModels/DTOs/TrackDto.cs index aea86a4..89fe0fa 100644 --- a/DeepDrftModels/DTOs/TrackDto.cs +++ b/DeepDrftModels/DTOs/TrackDto.cs @@ -1,4 +1,3 @@ -using DeepDrftModels.Enums; using Models.Models; namespace DeepDrftModels.DTOs; @@ -6,20 +5,17 @@ namespace DeepDrftModels.DTOs; // Inherits Id, CreatedAt, UpdatedAt from BaseModel (Cerebellum.BlazorBlocks.Models). // BlazorBlocks's Manager<> generic constraint requires `new()` on the model type, which // disqualifies `required` properties (the `new()` constraint and required members do not -// compose). EntryKey/TrackName/Artist therefore drop `required` here — the TrackEntity -// side remains required, and TrackConverter assigns every field on the round-trip so an -// empty default is never observable in production code paths. +// compose). EntryKey/TrackName therefore drop `required` here — the TrackEntity side remains +// required, and TrackConverter assigns every field on the round-trip so an empty default is +// never observable in production code paths. +// +// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields are read via Release?.X. public class TrackDto : BaseModel { public string EntryKey { get; set; } = string.Empty; public string TrackName { get; set; } = string.Empty; - public string Artist { get; set; } = string.Empty; - public string? Album { get; set; } - public string? Genre { get; set; } - public DateOnly? ReleaseDate { get; set; } - public string? ImagePath { get; set; } - public long? CreatedByUserId { get; set; } public string? OriginalFileName { get; set; } - public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; public int TrackNumber { get; set; } = 1; + public long? ReleaseId { get; set; } + public ReleaseDto? Release { get; set; } } diff --git a/DeepDrftModels/Entities/ReleaseEntity.cs b/DeepDrftModels/Entities/ReleaseEntity.cs new file mode 100644 index 0000000..47ee8c0 --- /dev/null +++ b/DeepDrftModels/Entities/ReleaseEntity.cs @@ -0,0 +1,23 @@ +using DeepDrftModels.Enums; +using Models.Entities; + +namespace DeepDrftModels.Entities; + +// The release-cardinal half of the normalized track schema (Phase 8 §8.0). One ReleaseEntity is +// shared by every track on the same album; track-cardinal data stays on TrackEntity, which points +// back here via a nullable ReleaseId (singles and loose tracks have no release context). +// +// 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 ReleaseEntity : BaseEntity, IEntity +{ + public required string Title { get; set; } + public required string Artist { get; set; } + public string? Genre { get; set; } + public DateOnly? ReleaseDate { get; set; } + public string? ImagePath { get; set; } + public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + public long? CreatedByUserId { get; set; } + public ICollection Tracks { get; set; } = new List(); +} diff --git a/DeepDrftModels/Entities/TrackEntity.cs b/DeepDrftModels/Entities/TrackEntity.cs index f60476f..50e1f1f 100644 --- a/DeepDrftModels/Entities/TrackEntity.cs +++ b/DeepDrftModels/Entities/TrackEntity.cs @@ -1,4 +1,3 @@ -using DeepDrftModels.Enums; using Models.Entities; namespace DeepDrftModels.Entities; @@ -6,17 +5,16 @@ namespace DeepDrftModels.Entities; // 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. +// +// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields (Artist, Album→Title, Genre, +// ReleaseDate, ImagePath, ReleaseType, CreatedByUserId) live on ReleaseEntity, reached via the +// nullable Release navigation; ReleaseId is null for singles and loose tracks. public class TrackEntity : BaseEntity, IEntity { public required string EntryKey { get; set; } public required string TrackName { get; set; } - public required string Artist { get; set; } - public string? Album { get; set; } - public string? Genre { get; set; } - public DateOnly? ReleaseDate { get; set; } - public string? ImagePath { get; set; } - public long? CreatedByUserId { get; set; } public string? OriginalFileName { get; set; } - public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; public int TrackNumber { get; set; } = 1; + public long? ReleaseId { get; set; } + public ReleaseEntity? Release { get; set; } } diff --git a/DeepDrftPublic.Client/Clients/TrackClient.cs b/DeepDrftPublic.Client/Clients/TrackClient.cs index 245fce4..f0f0933 100644 --- a/DeepDrftPublic.Client/Clients/TrackClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackClient.cs @@ -89,22 +89,22 @@ public class TrackClient : ApiResult.CreateFailResult("Failed to deserialize response"); } - public async Task>> GetAlbums() + public async Task>> GetAlbums() { var response = await _http.GetAsync("api/track/albums"); if (!response.IsSuccessStatusCode) - return ApiResult>.CreateFailResult($"HTTP {(int)response.StatusCode}"); + return ApiResult>.CreateFailResult($"HTTP {(int)response.StatusCode}"); var json = await response.Content.ReadAsStringAsync(); - var albums = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + var releases = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - return albums is not null - ? ApiResult>.CreatePassResult(albums) - : ApiResult>.CreateFailResult("Failed to deserialize response"); + return releases is not null + ? ApiResult>.CreatePassResult(releases) + : ApiResult>.CreateFailResult("Failed to deserialize response"); } public async Task>> GetGenres() diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor index c3784cb..1794115 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor @@ -13,26 +13,26 @@ - - @Track.Artist + @Track.Release?.Artist
- @if (!string.IsNullOrEmpty(Track.Genre)) + @if (!string.IsNullOrEmpty(Track.Release?.Genre)) { - @Track.Genre + @Track.Release.Genre } - @if (Track.ReleaseDate.HasValue) + @if (Track.Release?.ReleaseDate.HasValue == true) { - @Track.ReleaseDate.Value.Year + @Track.Release.ReleaseDate.Value.Year }
diff --git a/DeepDrftPublic.Client/Controls/NowPlayingCard.razor b/DeepDrftPublic.Client/Controls/NowPlayingCard.razor index 4299666..39e8e04 100644 --- a/DeepDrftPublic.Client/Controls/NowPlayingCard.razor +++ b/DeepDrftPublic.Client/Controls/NowPlayingCard.razor @@ -4,7 +4,7 @@
@(Player?.CurrentTrack?.TrackName ?? "Nothing playing")
@(Player?.CurrentTrack != null - ? $"{Player.CurrentTrack.Artist} · {Player.CurrentTrack.Album ?? "Single"}" + ? $"{Player.CurrentTrack.Release?.Artist} · {Player.CurrentTrack.Release?.Title ?? "Single"}" : "Select a track to begin")
diff --git a/DeepDrftPublic.Client/Controls/TrackCard.razor b/DeepDrftPublic.Client/Controls/TrackCard.razor index 2cafa67..750119b 100644 --- a/DeepDrftPublic.Client/Controls/TrackCard.razor +++ b/DeepDrftPublic.Client/Controls/TrackCard.razor @@ -1,7 +1,7 @@ @{ var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey); var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null; - var hasArt = !string.IsNullOrEmpty(TrackModel?.ImagePath); + var hasArt = !string.IsNullOrEmpty(TrackModel?.Release?.ImagePath); } @if (ViewMode == GalleryViewMode.Grid) @@ -13,9 +13,9 @@ @if (hasLink) { - @if (!string.IsNullOrEmpty(TrackModel?.ImagePath)) + @if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath)) { -
+
} else @@ -24,9 +24,9 @@ }
} - else if (!string.IsNullOrEmpty(TrackModel?.ImagePath)) + else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath)) { -
+
} else @@ -47,7 +47,7 @@ - @TrackModel?.Artist + @TrackModel?.Release?.Artist
@@ -62,38 +62,38 @@ - @TrackModel?.Artist + @TrackModel?.Release?.Artist
}
- @if (!string.IsNullOrEmpty(TrackModel?.Album)) + @if (!string.IsNullOrEmpty(TrackModel?.Release?.Title)) { - @TrackModel.Album + @TrackModel.Release!.Title } - @if (!string.IsNullOrEmpty(TrackModel?.Genre)) + @if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre)) { - @TrackModel.Genre + @TrackModel.Release!.Genre }
- @if (TrackModel?.ReleaseDate.HasValue == true) + @if (TrackModel?.Release?.ReleaseDate.HasValue == true) { - @TrackModel.ReleaseDate.Value.Year + @TrackModel.Release!.ReleaseDate!.Value.Year } else @@ -127,10 +127,10 @@ else { @* art thumb *@ - @if (!string.IsNullOrEmpty(TrackModel?.ImagePath)) + @if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath)) {
+ style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
} else @@ -141,7 +141,7 @@ else @* text block *@
- @TrackModel?.Artist + @TrackModel?.Release?.Artist @TrackModel?.TrackName @@ -150,20 +150,20 @@ else @* right metadata *@
- @if (!string.IsNullOrEmpty(TrackModel?.Genre)) + @if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre)) { - @TrackModel.Genre + @TrackModel.Release!.Genre } - @if (TrackModel?.ReleaseDate.HasValue == true) + @if (TrackModel?.Release?.ReleaseDate.HasValue == true) { - @TrackModel.ReleaseDate.Value.Year + @TrackModel.Release!.ReleaseDate!.Value.Year }
@@ -172,10 +172,10 @@ else else { @* same structure without anchor *@ - @if (!string.IsNullOrEmpty(TrackModel?.ImagePath)) + @if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath)) {
+ style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
} else @@ -184,27 +184,27 @@ else }
- @TrackModel?.Artist + @TrackModel?.Release?.Artist @TrackModel?.TrackName
- @if (!string.IsNullOrEmpty(TrackModel?.Genre)) + @if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre)) { - @TrackModel.Genre + @TrackModel.Release!.Genre } - @if (TrackModel?.ReleaseDate.HasValue == true) + @if (TrackModel?.Release?.ReleaseDate.HasValue == true) { - @TrackModel.ReleaseDate.Value.Year + @TrackModel.Release!.ReleaseDate!.Value.Year }
diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor b/DeepDrftPublic.Client/Pages/AlbumsView.razor index 70f3da9..173a6e8 100644 --- a/DeepDrftPublic.Client/Pages/AlbumsView.razor +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor @@ -33,11 +33,11 @@
- @if (!string.IsNullOrEmpty(album.CoverImageKey)) + @onclick="@(() => OpenAlbum(album.Title))"> + @if (!string.IsNullOrEmpty(album.ImagePath)) {
+ style="background-image: url('api/image/@Uri.EscapeDataString(album.ImagePath)');">
} else @@ -47,7 +47,7 @@
- @album.Album + @album.Title @album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks") diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor.cs b/DeepDrftPublic.Client/Pages/AlbumsView.razor.cs index 6406bfd..2c4e035 100644 --- a/DeepDrftPublic.Client/Pages/AlbumsView.razor.cs +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor.cs @@ -10,7 +10,7 @@ public partial class AlbumsView : ComponentBase [Inject] public required NavigationManager Navigation { get; set; } private bool _loading = true; - private List _albums = []; + private List _albums = []; protected override async Task OnInitializedAsync() { diff --git a/DeepDrftPublic.Client/Pages/TrackDetail.razor b/DeepDrftPublic.Client/Pages/TrackDetail.razor index 4c10ec1..10339a1 100644 --- a/DeepDrftPublic.Client/Pages/TrackDetail.razor +++ b/DeepDrftPublic.Client/Pages/TrackDetail.razor @@ -43,7 +43,9 @@ else if (ViewModel.Track is not null) var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id && PlayerService.IsPlaying && !PlayerService.IsPaused; - var hasMeta = track.Album is not null || track.Genre is not null || track.ReleaseDate is not null; + var release = track.Release; + var hasMeta = release is not null + && (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
@@ -54,7 +56,7 @@ else if (ViewModel.Track is not null)
@track.TrackName - @track.Artist + @release?.Artist
@@ -64,10 +66,10 @@ else if (ViewModel.Track is not null)
- @if (!string.IsNullOrEmpty(track.ImagePath)) + @if (!string.IsNullOrEmpty(release?.ImagePath)) { + Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" /> } else { @@ -82,31 +84,31 @@ else if (ViewModel.Track is not null)
- @if (track.Album is not null) + @if (release?.Title is not null) {
Album - @track.Album + @release.Title
} - @if (track.Genre is not null) + @if (release?.Genre is not null) {
- @track.Genre + @release.Genre
} - @if (track.ReleaseDate is not null) + @if (release?.ReleaseDate is not null) {
Released - @track.ReleaseDate.Value.ToString("MMMM yyyy") + @release.ReleaseDate.Value.ToString("MMMM yyyy")
}
diff --git a/DeepDrftPublic.Client/Services/ITrackDataService.cs b/DeepDrftPublic.Client/Services/ITrackDataService.cs index aa336c4..69b7caa 100644 --- a/DeepDrftPublic.Client/Services/ITrackDataService.cs +++ b/DeepDrftPublic.Client/Services/ITrackDataService.cs @@ -21,8 +21,8 @@ public interface ITrackDataService string? album = null, string? genre = null); - /// Distinct non-null albums with track counts and a representative cover key. - Task>> GetAlbums(); + /// All releases with track counts, title-ascending. + Task>> GetAlbums(); /// Distinct non-null genres with track counts. Task>> GetGenres(); diff --git a/DeepDrftPublic.Client/Services/TrackClientDataService.cs b/DeepDrftPublic.Client/Services/TrackClientDataService.cs index f3f5fab..ed75763 100644 --- a/DeepDrftPublic.Client/Services/TrackClientDataService.cs +++ b/DeepDrftPublic.Client/Services/TrackClientDataService.cs @@ -29,7 +29,7 @@ public class TrackClientDataService : ITrackDataService string? genre = null) => _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre); - public Task>> GetAlbums() + public Task>> GetAlbums() => _trackClient.GetAlbums(); public Task>> GetGenres() diff --git a/DeepDrftTests/TrackFilterQueryTests.cs b/DeepDrftTests/TrackFilterQueryTests.cs index ee35de3..43146e6 100644 --- a/DeepDrftTests/TrackFilterQueryTests.cs +++ b/DeepDrftTests/TrackFilterQueryTests.cs @@ -10,15 +10,18 @@ using Models.Common; namespace DeepDrftTests; /// -/// Query-shape tests for the Phase 2.2/2.3 filter and distinct-browse repository methods. +/// Query-shape tests for the filter and distinct-browse repository methods, updated for the +/// Phase 8 §8.0 normalized schema: release-cardinal data (Artist, Album→Title, Genre, ImagePath) +/// lives on , reached through the nullable Release navigation. Tracks +/// link via ReleaseId; loose tracks have a null release. /// /// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That -/// covers exact-match equality, null passthrough, GroupBy/Count, and ordering — every predicate -/// in except the free-text branch. That branch -/// uses EF.Functions.ILike, an Npgsql-only relational function with no in-memory translation, -/// so the SearchText case is a Postgres integration test gated on a DSN (see SearchText_*). It is -/// ignored when no test database is configured rather than asserted against a provider that never -/// runs the predicate. +/// covers exact-match equality through the navigation, null passthrough, GroupBy/Count, and +/// ordering — every predicate in except the +/// free-text branch. That branch uses EF.Functions.ILike, an Npgsql-only relational +/// function with no in-memory translation, so the SearchText case is a Postgres integration test +/// gated on a DSN (see SearchText_*). It is ignored when no test database is configured rather +/// than asserted against a provider that never runs the predicate. /// [TestFixture] public class TrackFilterQueryTests @@ -43,16 +46,23 @@ public class TrackFilterQueryTests private TrackRepository CreateRepository() => new(_context, NullLogger>.Instance); - private static TrackEntity Track( - string name, string artist, string? album = null, string? genre = null, string? image = null) + private static ReleaseEntity Release( + string title, string artist, string? genre = null, string? image = null) + => new() + { + Title = title, + Artist = artist, + Genre = genre, + ImagePath = image, + }; + + // A track linked to the given release (or loose when release is null). + private static TrackEntity Track(string name, ReleaseEntity? release = null) => new() { EntryKey = Guid.NewGuid().ToString("N"), TrackName = name, - Artist = artist, - Album = album, - Genre = genre, - ImagePath = image, + Release = release, }; private async Task SeedAsync(params TrackEntity[] tracks) @@ -64,16 +74,18 @@ public class TrackFilterQueryTests private static PagingParameters DefaultPaging() => new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false }; - // Case 2 — exact album match: returns only rows whose Album equals the filter value, and - // TotalCount reflects the filtered set, not the table. + // Case 2 — exact album match: returns only rows whose linked release Title equals the filter + // value, and TotalCount reflects the filtered set, not the table. [Test] public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum() { + var blue = Release("Blue", "A"); + var red = Release("Red", "C"); await SeedAsync( - Track("One", "A", album: "Blue"), - Track("Two", "B", album: "Blue"), - Track("Three", "C", album: "Red"), - Track("Four", "D", album: null)); + Track("One", blue), + Track("Two", blue), + Track("Three", red), + Track("Four")); var repo = CreateRepository(); var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" }); @@ -82,14 +94,14 @@ public class TrackFilterQueryTests Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" })); } - // Case 2b — exact genre match composes the same way as album. + // Case 2b — exact genre match composes the same way as album, through the release join. [Test] public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre() { await SeedAsync( - Track("One", "A", genre: "Techno"), - Track("Two", "B", genre: "House"), - Track("Three", "C", genre: "Techno")); + Track("One", Release("A1", "A", genre: "Techno")), + Track("Two", Release("A2", "B", genre: "House")), + Track("Three", Release("A3", "C", genre: "Techno"))); var repo = CreateRepository(); var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Genre = "Techno" }); @@ -103,9 +115,9 @@ public class TrackFilterQueryTests public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery() { await SeedAsync( - Track("One", "A", album: "Blue"), - Track("Two", "B", album: "Red"), - Track("Three", "C")); + Track("One", Release("Blue", "A")), + Track("Two", Release("Red", "B")), + Track("Three")); var repo = CreateRepository(); var baseline = await repo.GetPagedAsync(DefaultPaging()); @@ -117,56 +129,98 @@ public class TrackFilterQueryTests Is.EqualTo(baseline.Items.Select(t => t.Id)).AsCollection); } - // Case 4 — distinct albums: excludes null-album rows, counts per group, and takes the cover from - // the first track in the group that has a non-null ImagePath. Ordered by album ascending. + // Case 4 — releases: returns every non-deleted release, title-ascending. Replaces the old + // distinct-albums grouping; one row per release rather than per distinct album string. [Test] - public async Task GetDistinctAlbumsAsync_GroupsCountsAndPicksCover() + public async Task GetReleasesAsync_ReturnsAllReleasesTitleAscending() { await SeedAsync( - Track("One", "A", album: "Zephyr", image: null), - Track("Two", "A", album: "Zephyr", image: "cover-z"), - Track("Three", "B", album: "Aria", image: "cover-a"), - Track("Four", "C", album: null, image: "ignored")); + Track("One", Release("Zephyr", "A", image: "cover-z")), + Track("Two", Release("Aria", "B", image: "cover-a")), + Track("Three")); var repo = CreateRepository(); - var albums = await repo.GetDistinctAlbumsAsync(); + var releases = await repo.GetReleasesAsync(); - Assert.That(albums.Select(a => a.Album), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection, - "albums sort ascending and the null-album track is excluded"); - - var zephyr = albums.Single(a => a.Album == "Zephyr"); - Assert.That(zephyr.TrackCount, Is.EqualTo(2)); - Assert.That(zephyr.CoverImageKey, Is.EqualTo("cover-z"), - "cover is the first non-null ImagePath in the group"); - - var aria = albums.Single(a => a.Album == "Aria"); - Assert.That(aria.TrackCount, Is.EqualTo(1)); - Assert.That(aria.CoverImageKey, Is.EqualTo("cover-a")); + Assert.That(releases.Select(r => r.Title), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection, + "releases sort by title ascending; the loose track contributes none"); + Assert.That(releases.Single(r => r.Title == "Zephyr").ImagePath, Is.EqualTo("cover-z")); + Assert.That(releases.Single(r => r.Title == "Aria").ImagePath, Is.EqualTo("cover-a")); } - // Case 5 — distinct genres: excludes null-genre rows, counts per group, ordered genre ascending. + // Case 4b — per-release track counts: keyed by ReleaseId, counting only non-deleted tracks. + // Loose tracks (null ReleaseId) contribute no entry. Backs ReleaseDto.TrackCount. + [Test] + public async Task GetTrackCountsByReleaseAsync_CountsTracksPerRelease() + { + var zephyr = Release("Zephyr", "A"); + var aria = Release("Aria", "B"); + await SeedAsync( + Track("One", zephyr), + Track("Two", zephyr), + Track("Three", aria), + Track("Four")); + + var repo = CreateRepository(); + var counts = await repo.GetTrackCountsByReleaseAsync(); + + Assert.That(counts[zephyr.Id], Is.EqualTo(2)); + Assert.That(counts[aria.Id], Is.EqualTo(1)); + Assert.That(counts.Count, Is.EqualTo(2), "the loose track contributes no release key"); + } + + // Case 5 — distinct genres: sourced from the release join, excludes releases with null genre, + // counts tracks per genre, ordered genre ascending. [Test] public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull() { await SeedAsync( - Track("One", "A", genre: "Techno"), - Track("Two", "B", genre: "Ambient"), - Track("Three", "C", genre: "Techno"), - Track("Four", "D", genre: null)); + Track("One", Release("A1", "A", genre: "Techno")), + Track("Two", Release("A2", "B", genre: "Ambient")), + Track("Three", Release("A3", "C", genre: "Techno")), + Track("Four", Release("A4", "D"))); var repo = CreateRepository(); var genres = await repo.GetDistinctGenresAsync(); Assert.That(genres.Select(g => g.Genre), Is.EqualTo(new[] { "Ambient", "Techno" }).AsCollection, - "genres sort ascending and the null-genre track is excluded"); + "genres sort ascending and the null-genre release is excluded"); Assert.That(genres.Single(g => g.Genre == "Techno").TrackCount, Is.EqualTo(2)); Assert.That(genres.Single(g => g.Genre == "Ambient").TrackCount, Is.EqualTo(1)); } - // Case 1 — free-text search across TrackName/Artist/Album, case-insensitive. EF.Functions.ILike - // is Npgsql-only and does not translate on the in-memory provider, so this runs only against a - // real Postgres database supplied via the DEEPDRFT_TEST_PG environment variable. Without it the - // test is ignored rather than asserted against a provider that cannot execute the predicate. + // Case 6 — find-or-create resolution: an existing (title, artist) returns the stored row, no + // duplicate insert. Exercises the natural-key lookup that backs the upload FK resolution. + [Test] + public async Task GetReleaseByTitleAndArtistAsync_ReturnsExistingMatch() + { + var blue = Release("Blue", "Artist A"); + await SeedAsync(Track("One", blue)); + + var repo = CreateRepository(); + var found = await repo.GetReleaseByTitleAndArtistAsync("Blue", "Artist A"); + + Assert.That(found, Is.Not.Null); + Assert.That(found!.Id, Is.EqualTo(blue.Id)); + } + + // Case 6b — no match returns null so the manager creates a fresh release. + [Test] + public async Task GetReleaseByTitleAndArtistAsync_ReturnsNullWhenNoMatch() + { + await SeedAsync(Track("One", Release("Blue", "Artist A"))); + + var repo = CreateRepository(); + var found = await repo.GetReleaseByTitleAndArtistAsync("Red", "Artist A"); + + Assert.That(found, Is.Null); + } + + // Case 1 — free-text search across TrackName plus the joined release Artist/Title, + // case-insensitive. EF.Functions.ILike is Npgsql-only and does not translate on the in-memory + // provider, so this runs only against a real Postgres database supplied via the + // DEEPDRFT_TEST_PG environment variable. Without it the test is ignored rather than asserted + // against a provider that cannot execute the predicate. [Test] public async Task GetPagedFilteredAsync_WithSearchText_MatchesNameArtistOrAlbumCaseInsensitive() { @@ -183,10 +237,10 @@ public class TrackFilterQueryTests try { pg.Tracks.AddRange( - Track("Jazz Odyssey", "Spinal Tap", album: "Smell the Glove"), - Track("Quiet Storm", "jazzmin", album: "Nightfall"), - Track("Loud Noises", "Brick", album: "All JAZZ Hands"), - Track("Unrelated", "Nobody", album: "Silence")); + Track("Jazz Odyssey", Release("Smell the Glove", "Spinal Tap")), + Track("Quiet Storm", Release("Nightfall", "jazzmin")), + Track("Loud Noises", Release("All JAZZ Hands", "Brick")), + Track("Unrelated", Release("Silence", "Nobody"))); await pg.SaveChangesAsync(); var repo = new TrackRepository( @@ -195,7 +249,7 @@ public class TrackFilterQueryTests Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "Jazz Odyssey", "Quiet Storm", "Loud Noises" }), - "ILike matches 'jazz' case-insensitively in TrackName, Artist, or Album"); + "ILike matches 'jazz' case-insensitively in TrackName, release Artist, or release Title"); } finally { From 70d4a87cd56ab6fe049f5cba02d38545e7288620 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 14:48:52 -0400 Subject: [PATCH 2/2] fix: include Release nav on all TrackRepository query paths; add unique constraint on release(title, artist) --- .../Configurations/ReleaseConfiguration.cs | 7 + ...32_AddReleaseUniqueTitleArtist.Designer.cs | 178 ++++++++++++++++++ ...60611184732_AddReleaseUniqueTitleArtist.cs | 28 +++ .../DeepDrftContextModelSnapshot.cs | 4 + DeepDrftData/Repositories/TrackRepository.cs | 10 + DeepDrftData/TrackManager.cs | 26 ++- 6 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.Designer.cs create mode 100644 DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.cs diff --git a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs index cd49e94..d0260aa 100644 --- a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs +++ b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs @@ -56,5 +56,12 @@ public class ReleaseConfiguration : BaseEntityConfiguration // calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses // "IX_release_is_deleted" regardless of auto-naming conventions. builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_release_is_deleted"); + + // Unique constraint on the natural key (title + artist). Prevents duplicate release rows + // from concurrent uploads of the same album. The FindOrCreateRelease path catches the + // resulting ClassifiedDbException (UniqueViolation) and re-queries for the winning row. + builder.HasIndex(e => new { e.Title, e.Artist }) + .IsUnique() + .HasDatabaseName("IX_release_title_artist"); } } diff --git a/DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.Designer.cs b/DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.Designer.cs new file mode 100644 index 0000000..1bb5211 --- /dev/null +++ b/DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.Designer.cs @@ -0,0 +1,178 @@ +// +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("20260611184732_AddReleaseUniqueTitleArtist")] + partial class AddReleaseUniqueTitleArtist + { + /// + 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.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("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"); + + b.ToTable("release", (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.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("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.cs b/DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.cs new file mode 100644 index 0000000..3c05849 --- /dev/null +++ b/DeepDrftData/Migrations/20260611184732_AddReleaseUniqueTitleArtist.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class AddReleaseUniqueTitleArtist : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_release_title_artist", + table: "release", + columns: new[] { "title", "artist" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_release_title_artist", + table: "release"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 84aa92a..1a1c42e 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -88,6 +88,10 @@ namespace DeepDrftData.Migrations b.HasIndex("IsDeleted") .HasDatabaseName("IX_release_is_deleted"); + b.HasIndex("Title", "Artist") + .IsUnique() + .HasDatabaseName("IX_release_title_artist"); + b.ToTable("release", (string)null); }); diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index b10c602..b39d33a 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -26,6 +26,16 @@ public class TrackRepository : Repository _context = context; } + // Override base GetByIdAsync to include the Release navigation. Without this, the base + // Query has no .Include, so Release is null on every entity (no lazy-loading proxies). + public override async Task GetByIdAsync(long id) + => await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id); + + // Override base GetAllAsync for the same reason — include Release so callers (e.g. + // TrackManager.GetAll) receive fully-populated entities without a separate query. + public override async Task> GetAllAsync() + => await Query.Include(t => t.Release).ToListAsync(); + // Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this // uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the // converter can project the release-cardinal fields. diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index e92e37e..043d3c8 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -1,3 +1,4 @@ +using Data.Errors; using Data.Managers; using DeepDrftData.Repositories; using DeepDrftModels.DTOs; @@ -121,13 +122,12 @@ public class TrackManager } }; - // An all-null filter must produce identical results to no filter, so collapse it to - // null and take the unfiltered base path (preserves backward compatibility). + // Always route through GetPagedFilteredAsync — it handles a null filter by skipping + // all Where predicates, and it always includes Release. This removes the base-class + // GetPagedAsync path, which has no .Include and would return entities with null Release. var effectiveFilter = filter is null || filter.IsEmpty ? null : filter; - var page = effectiveFilter is null - ? await Repository.GetPagedAsync(parameters) - : await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken); + var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken); var dtoPage = PagedResult.From(page, page.Items.Select(TrackConverter.Convert)); return ResultContainer>.CreatePassResult(dtoPage); @@ -178,8 +178,20 @@ public class TrackManager entity.Title = title; entity.Artist = artist; - var added = await Repository.AddReleaseAsync(entity, cancellationToken); - return ResultContainer.CreatePassResult(TrackConverter.Convert(added)); + try + { + var added = await Repository.AddReleaseAsync(entity, cancellationToken); + return ResultContainer.CreatePassResult(TrackConverter.Convert(added)); + } + catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation) + { + // Concurrent upload inserted the same (title, artist) between our read and write. + // Re-query and return the winning row. Should not return null here since the + // constraint just fired, but re-throw if it does so the caller sees an error. + var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken); + if (race is null) throw; + return ResultContainer.CreatePassResult(TrackConverter.Convert(race)); + } } catch (Exception e) {