feat: normalize release-cardinal fields out of track into a Release entity (Phase 8 §8.0)

This commit is contained in:
daniel-c-harvey
2026-06-11 12:51:21 -04:00
parent 16f356a760
commit f767d288c5
33 changed files with 1032 additions and 297 deletions
+27 -16
View File
@@ -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<ReleaseDto>.
[HttpGet("albums")]
public async Task<ActionResult> 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)
{
+35 -3
View File
@@ -65,11 +65,43 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.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<TrackDto>.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
+3 -5
View File
@@ -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
};
@@ -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<ReleaseEntity>
{
public override void Configure(EntityTypeBuilder<ReleaseEntity> 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<string>() // 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");
}
}
@@ -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<TrackEntity>
.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<string>() // 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.
+2
View File
@@ -11,11 +11,13 @@ public class DeepDrftContext : DbContext
}
public DbSet<TrackEntity> Tracks { get; set; }
public DbSet<ReleaseEntity> Releases { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
}
}
+10 -2
View File
@@ -22,12 +22,20 @@ public interface ITrackService
Task<ResultContainer<List<TrackDto>>> GetAll();
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
/// <summary>Distinct non-null albums with track counts and a representative cover key, album-ascending.</summary>
Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default);
/// <summary>All releases, title-ascending, each carrying its non-deleted track count.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default);
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
/// <summary>
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
/// </summary>
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
Task<ResultContainer<TrackDto>> Update(TrackDto track);
Task<Result> Delete(long id);
@@ -0,0 +1,174 @@
// <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("20260611164537_NormalizeReleaseTrack")]
partial class NormalizeReleaseTrack
{
/// <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.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<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.ToTable("release", (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.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
}
}
}
@@ -0,0 +1,184 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class NormalizeReleaseTrack : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Create the release table.
migrationBuilder.CreateTable(
name: "release",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
artist = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
genre = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
release_date = table.Column<DateOnly>(type: "date", nullable: true),
image_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
release_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Single"),
created_by_user_id = table.Column<long>(type: "bigint", nullable: true),
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_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<long>(
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");
}
/// <inheritdoc />
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<string>(
name: "album",
table: "track",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "artist",
table: "track",
type: "character varying(200)",
maxLength: 200,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "genre",
table: "track",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "image_path",
table: "track",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "release_date",
table: "track",
type: "date",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "release_type",
table: "track",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Single");
migrationBuilder.AddColumn<long>(
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");
}
}
}
@@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -31,11 +31,6 @@ namespace DeepDrftData.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
@@ -50,12 +45,6 @@ namespace DeepDrftData.Migrations
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
@@ -72,11 +61,6 @@ namespace DeepDrftData.Migrations
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
@@ -89,6 +73,58 @@ namespace DeepDrftData.Migrations
.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.ToTable("release", (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)
@@ -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
}
}
+73 -35
View File
@@ -11,18 +11,26 @@ namespace DeepDrftData.Repositories;
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
{
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) 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<Repository<DeepDrftContext, TrackEntity>> 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<TrackEntity?> 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<DeepDrftContext, TrackEntity>
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<DeepDrftContext, TrackEntity>
TrackFilter? filter,
CancellationToken ct = default)
{
IQueryable<TrackEntity> query = Query;
// Include Release so both the filter predicates and the converter can read release-cardinal
// fields through the navigation.
IQueryable<TrackEntity> 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<DeepDrftContext, TrackEntity>
};
}
// 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<List<AlbumSummaryDto>> 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<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.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<List<GenreSummaryDto>> 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<DeepDrftContext, TrackEntity>
.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<Dictionary<long, int>> 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<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
string title, string artist, CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.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<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
{
_context.Set<ReleaseEntity>().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<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.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<ReleaseEntity>().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;
}
}
+39 -16
View File
@@ -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.
/// </summary>
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
{
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<TrackEntity, TrackDto>
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<TrackEntity, TrackDto>
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
};
}
+83 -8
View File
@@ -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<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default)
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
{
try
{
var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken);
return ResultContainer<List<AlbumSummaryDto>>.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<List<ReleaseDto>>.CreatePassResult(dtos);
}
catch (Exception e)
{
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult(e.Message);
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<ReleaseDto>> 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<ReleaseDto>.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<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
}
catch (Exception e)
{
return ResultContainer<ReleaseDto>.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<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
return ResultContainer<TrackDto>.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<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
+1 -1
View File
@@ -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)
{
@@ -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
};
}
@@ -52,10 +52,10 @@
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Artist">@context.Artist</MudTd>
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
<MudTd DataLabel="File Name"><MudText Typo="Typo.caption" Style="font-family: monospace;">@(context.OriginalFileName ?? "—")</MudText></MudTd>
<MudTd DataLabel="Actions">
@@ -216,7 +216,7 @@
{
var confirmed = await DialogService.ShowMessageBox(
title: "Delete track",
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Artist)}? This removes both the metadata row and the underlying audio entry."),
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
yesText: "Delete",
cancelText: "Cancel");
+13 -13
View File
@@ -450,7 +450,7 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<ResultContainer<List<AlbumSummaryDto>>> GetAlbumSummariesAsync(CancellationToken ct = default)
public async Task<ResultContainer<List<ReleaseDto>>> 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<List<AlbumSummaryDto>>.CreateFailResult("Content API is unreachable.");
_logger.LogError(ex, "Content API call failed for releases");
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API album summaries failed: {Status}", (int)response.StatusCode);
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult("Failed to load albums.");
_logger.LogError("Content API releases failed: {Status}", (int)response.StatusCode);
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Failed to load albums.");
}
List<AlbumSummaryDto>? albums;
List<ReleaseDto>? releases;
try
{
albums = await response.Content.ReadFromJsonAsync<List<AlbumSummaryDto>>(ct);
releases = await response.Content.ReadFromJsonAsync<List<ReleaseDto>>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize album summaries from Content API response");
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
_logger.LogError(ex, "Failed to deserialize releases from Content API response");
return ResultContainer<List<ReleaseDto>>.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<List<AlbumSummaryDto>>.CreateFailResult("Content API returned an empty response.");
_logger.LogError("Content API returned a null releases list");
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<List<AlbumSummaryDto>>.CreatePassResult(albums);
return ResultContainer<List<ReleaseDto>>.CreatePassResult(releases);
}
}
+2 -2
View File
@@ -88,8 +88,8 @@ public interface ICmsTrackService
/// </summary>
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
/// <summary>Returns all distinct albums with track counts from GET api/track/albums.</summary>
Task<ResultContainer<List<AlbumSummaryDto>>> GetAlbumSummariesAsync(CancellationToken ct = default);
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
+1
View File
@@ -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.
/// </summary>
[Obsolete("Replaced by ReleaseDto. Use ITrackService.GetReleases().")]
public class AlbumSummaryDto
{
public required string Album { get; set; }
+24
View File
@@ -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; }
}
+7 -11
View File
@@ -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; }
}
+23
View File
@@ -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<TrackEntity> Tracks { get; set; } = new List<TrackEntity>();
}
+6 -8
View File
@@ -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; }
}
+6 -6
View File
@@ -89,22 +89,22 @@ public class TrackClient
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
public async Task<ApiResult<List<ReleaseDto>>> GetAlbums()
{
var response = await _http.GetAsync("api/track/albums");
if (!response.IsSuccessStatusCode)
return ApiResult<List<AlbumSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
return ApiResult<List<ReleaseDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var albums = JsonSerializer.Deserialize<List<AlbumSummaryDto>>(json, new JsonSerializerOptions
var releases = JsonSerializer.Deserialize<List<ReleaseDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return albums is not null
? ApiResult<List<AlbumSummaryDto>>.CreatePassResult(albums)
: ApiResult<List<AlbumSummaryDto>>.CreateFailResult("Failed to deserialize response");
return releases is not null
? ApiResult<List<ReleaseDto>>.CreatePassResult(releases)
: ApiResult<List<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
@@ -13,26 +13,26 @@
</a>
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
@Track.Artist
@Track.Release?.Artist
</MudText>
</div>
<div class="track-meta-accents">
@if (!string.IsNullOrEmpty(Track.Genre))
@if (!string.IsNullOrEmpty(Track.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@Track.Genre
@Track.Release.Genre
</MudChip>
}
@if (Track.ReleaseDate.HasValue)
@if (Track.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="track-meta-year">
@Track.ReleaseDate.Value.Year
@Track.Release.ReleaseDate.Value.Year
</MudText>
}
</div>
@@ -4,7 +4,7 @@
<div class="np-title">@(Player?.CurrentTrack?.TrackName ?? "Nothing playing")</div>
<div class="np-sub">
@(Player?.CurrentTrack != null
? $"{Player.CurrentTrack.Artist} · {Player.CurrentTrack.Album ?? "Single"}"
? $"{Player.CurrentTrack.Release?.Artist} · {Player.CurrentTrack.Release?.Title ?? "Single"}"
: "Select a track to begin")
</div>
+27 -27
View File
@@ -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)
{
<a href="@trackHref" class="deepdrft-track-card-link">
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
@@ -24,9 +24,9 @@
}
</a>
}
else if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
@@ -47,7 +47,7 @@
<MudText Typo="Typo.caption"
Class="deepdrft-track-artist text-truncate mb-2">
@TrackModel?.Artist
@TrackModel?.Release?.Artist
</MudText>
</div>
</a>
@@ -62,38 +62,38 @@
<MudText Typo="Typo.caption"
Class="deepdrft-track-artist text-truncate mb-2">
@TrackModel?.Artist
@TrackModel?.Release?.Artist
</MudText>
</div>
}
<div class="deepdrft-track-info-middle">
@if (!string.IsNullOrEmpty(TrackModel?.Album))
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Title))
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta text-truncate">
@TrackModel.Album
@TrackModel.Release!.Title
</MudText>
}
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Genre
@TrackModel.Release!.Genre
</MudChip>
}
</div>
<div class="deepdrft-track-info-bottom">
@if (TrackModel?.ReleaseDate.HasValue == true)
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta">
@TrackModel.ReleaseDate.Value.Year
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
else
@@ -127,10 +127,10 @@ else
{
<a href="@trackHref" class="deepdrft-track-row-link">
@* art thumb *@
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
@@ -141,7 +141,7 @@ else
@* text block *@
<div class="deepdrft-track-row-text">
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
@TrackModel?.Artist
@TrackModel?.Release?.Artist
</MudText>
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
@TrackModel?.TrackName
@@ -150,20 +150,20 @@ else
@* right metadata *@
<div class="deepdrft-track-row-meta">
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Genre
@TrackModel.Release!.Genre
</MudChip>
}
@if (TrackModel?.ReleaseDate.HasValue == true)
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.ReleaseDate.Value.Year
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
</div>
@@ -172,10 +172,10 @@ else
else
{
@* same structure without anchor *@
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
@@ -184,27 +184,27 @@ else
}
<div class="deepdrft-track-row-text">
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
@TrackModel?.Artist
@TrackModel?.Release?.Artist
</MudText>
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
@TrackModel?.TrackName
</MudText>
</div>
<div class="deepdrft-track-row-meta">
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Genre
@TrackModel.Release!.Genre
</MudChip>
}
@if (TrackModel?.ReleaseDate.HasValue == true)
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.ReleaseDate.Value.Year
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
</div>
+4 -4
View File
@@ -33,11 +33,11 @@
<div class="album-card"
role="button"
tabindex="0"
@onclick="@(() => OpenAlbum(album.Album))">
@if (!string.IsNullOrEmpty(album.CoverImageKey))
@onclick="@(() => OpenAlbum(album.Title))">
@if (!string.IsNullOrEmpty(album.ImagePath))
{
<div class="album-card-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(album.CoverImageKey)');">
style="background-image: url('api/image/@Uri.EscapeDataString(album.ImagePath)');">
</div>
}
else
@@ -47,7 +47,7 @@
<div class="album-card-body">
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
@album.Album
@album.Title
</MudText>
<MudText Typo="Typo.caption" Class="album-card-count">
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
@@ -10,7 +10,7 @@ public partial class AlbumsView : ComponentBase
[Inject] public required NavigationManager Navigation { get; set; }
private bool _loading = true;
private List<AlbumSummaryDto> _albums = [];
private List<ReleaseDto> _albums = [];
protected override async Task OnInitializedAsync()
{
+12 -10
View File
@@ -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);
<div class="deepdrft-track-detail-container">
@@ -54,7 +56,7 @@ else if (ViewModel.Track is not null)
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@track.TrackName</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@track.Artist</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@release?.Artist</MudText>
</div>
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
@@ -64,10 +66,10 @@ else if (ViewModel.Track is not null)
</MudStack>
<div class="deepdrft-track-detail-cover">
@if (!string.IsNullOrEmpty(track.ImagePath))
@if (!string.IsNullOrEmpty(release?.ImagePath))
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(track.ImagePath)}');")" />
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
}
else
{
@@ -82,31 +84,31 @@ else if (ViewModel.Track is not null)
<MudDivider />
<div class="deepdrft-track-detail-meta">
@if (track.Album is not null)
@if (release?.Title is not null)
{
<div>
<MudText Typo="Typo.overline">Album</MudText>
<MudText Typo="Typo.body1">@track.Album</MudText>
<MudText Typo="Typo.body1">@release.Title</MudText>
</div>
}
@if (track.Genre is not null)
@if (release?.Genre is not null)
{
<div>
<MudChip T="string"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@track.Genre
@release.Genre
</MudChip>
</div>
}
@if (track.ReleaseDate is not null)
@if (release?.ReleaseDate is not null)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@track.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
</div>
}
</div>
@@ -21,8 +21,8 @@ public interface ITrackDataService
string? album = null,
string? genre = null);
/// <summary>Distinct non-null albums with track counts and a representative cover key.</summary>
Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums();
/// <summary>All releases with track counts, title-ascending.</summary>
Task<ApiResult<List<ReleaseDto>>> GetAlbums();
/// <summary>Distinct non-null genres with track counts.</summary>
Task<ApiResult<List<GenreSummaryDto>>> GetGenres();
@@ -29,7 +29,7 @@ public class TrackClientDataService : ITrackDataService
string? genre = null)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
public Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
public Task<ApiResult<List<ReleaseDto>>> GetAlbums()
=> _trackClient.GetAlbums();
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
+114 -60
View File
@@ -10,15 +10,18 @@ using Models.Common;
namespace DeepDrftTests;
/// <summary>
/// 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 <see cref="ReleaseEntity"/>, reached through the nullable Release navigation. Tracks
/// link via <c>ReleaseId</c>; 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 <see cref="TrackRepository.GetPagedFilteredAsync"/> except the free-text branch. That branch
/// uses <c>EF.Functions.ILike</c>, 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 <see cref="TrackRepository.GetPagedFilteredAsync"/> except the
/// free-text branch. That branch uses <c>EF.Functions.ILike</c>, 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.
/// </summary>
[TestFixture]
public class TrackFilterQueryTests
@@ -43,16 +46,23 @@ public class TrackFilterQueryTests
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.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<TrackEntity> 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 5distinct genres: excludes null-genre rows, counts per group, ordered genre ascending.
// Case 4bper-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
{