Merge branch 'p8-w12-release-track-normalize' into dev
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,67 @@
|
||||
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");
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// <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("20260611184732_AddReleaseUniqueTitleArtist")]
|
||||
partial class AddReleaseUniqueTitleArtist
|
||||
{
|
||||
/// <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.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
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,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseUniqueTitleArtist : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release",
|
||||
columns: new[] { "title", "artist" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "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,62 @@ 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.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
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 +150,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,36 @@ 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;
|
||||
}
|
||||
|
||||
// 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<TrackEntity?> 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<IEnumerable<TrackEntity>> 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.
|
||||
// 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 +55,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 +72,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 +120,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 +143,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
+100
-13
@@ -1,3 +1,4 @@
|
||||
using Data.Errors;
|
||||
using Data.Managers;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
@@ -107,24 +108,26 @@ 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
|
||||
}
|
||||
};
|
||||
|
||||
// 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<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
||||
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
||||
@@ -135,16 +138,64 @@ 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;
|
||||
|
||||
try
|
||||
{
|
||||
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
||||
return ResultContainer<ReleaseDto>.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<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +216,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 +248,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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user